
A deep read of Justin Le's August 4 essay on how Haskell's parametric polymorphism turns missing type information into guarantees about implementations, data shape, and resource scope.
a -> a and [a] -> [a] into concrete limits on what code can do, then carries the same idea into records, higher-kinded data, resource scopes, and everyday application code. 1static <T> T foo(T x) can inspect the runtime class and treat Integer specially. TypeScript's function foo<T>(x: T): T has the same escape route when code uses runtime checks and casts. Under the ordinary assumptions of pure parametric reasoning, Haskell's equivalent is different:foo :: a -> a
foo x = xforall is implicit, so the signature means forall a. a -> a. The function must work for every type a, and it has no operation that can learn which a it received. Its only lawful result is the value it was given. 1g :: b -> b and g :: forall b. b -> b are treated identically, except that the written forall can bring the type variable into scope. The RankNTypes extension then permits higher-rank types, where a forall can appear inside a function argument or result. 2unsafePerformIO and unsafeCoerce for this discussion. The guarantee belongs to the parametric fragment being discussed, not to every runtime behavior available in the language. 1mystery :: a -> b -> ab is unrelated to the result type a. The function must return the first input. Or consider:theThing :: [a] -> [a]a, invent a new a, or change an empty list into a non-empty one. The type still leaves many implementations open; it simply fences them into a smaller space.theThing . map f == map f . theThingtheThing, or run theThing and map afterward: the result is the same because theThing cannot depend on the values that f changes. sort :: [Int] -> [Int] does not enjoy that law, because sorting can inspect the integers. take 3 :: [a] -> [a] does. Le tests the difference with abs: sorting before and after abs produces different lists, while take 3 produces the same list either way. 1
doIt :: [a] -> Maybe a may choose an element by position, but it cannot choose the smallest or largest element without an Ord a constraint. It must return Nothing for an empty list, and it commutes with map and fmap.collapse :: [a] -> Int cannot use the list's contents, so its result can depend on the length or be constant. sum is ruled out; length is allowed.duper :: a -> [a] can only return copies of the input value. replicate n fits; a function that adds 1 to an Int does not, because it would fail for another a.consumeInt :: forall r. (Int -> r) -> r must apply the supplied continuation to one fixed Int. The result type r is arbitrary, so the function cannot manufacture an r by any other route.a appears to promise less than one that accepts Int. Here, the loss of information is what creates the promise.[Int] -> [Int]
Num a => [a] -> [a]
Ord a => [a] -> [a]
[a] -> [a]Num a allows numerical operations. Ord a allows comparisons, while the output still has to come from the input values. The fully parametric version cannot inspect the elements at all; it can only change their order and multiplicity.traverseIO :: (a -> IO b) -> [a] -> IO [b]
traverse :: Applicative f => (a -> f b) -> [a] -> f [b]a -> f b action and the Applicative structure; the traversal cannot quietly add an unrelated IO action behind the caller's back. The point is not that Applicative makes effects harmless. It is that the signature says which effects the function is allowed to compose.data User uid = User
{ userId :: uid
, userName :: String
, userAge :: Int
}
processUser :: User uid -> IO (User uid)uid with a new value, because it has no way to construct a value of the universally quantified uid. The invariant is attached to this operation, not permanently baked into every use of User.t and the processing function is constrained by Traversable t, then traversing the items can update each item while preserving the container's slots and shape:data Checklist t = Checklist
{ updated :: UTCTime
, items :: t (Status, String)
}
updateItems :: Traversable t
=> Checklist t
-> IO (Checklist t)UserF f example pushes further. With a higher-kinded data type, the same record can hold ordinary values, optional values, parsers, or documentation:data UserF f = UserF
{ userName :: f String
, userAge :: f Int
}
processUser :: Functor f => UserF f -> UserF ff. With Maybe, it cannot turn a missing field into a present one. With a parser type, it cannot change which strings parse successfully. With Const Doc, it cannot modify the documentation. One polymorphic implementation inherits different guarantees when instantiated at different shapes. 1forall can close a resource scopeST.Memory can be passed into an inner Memory, where the same integer may refer to nothing or to a different value. A variable can also escape after the store that gave it meaning has been discarded.newtype Var s = Var Int
newtype Memory s v = Memory { getMemory :: IntMap v }
initVar :: v -> State (Memory s v) (Var s)
readVar :: Var s -> State (Memory s v) v
writeVar :: Var s -> v -> State (Memory s v) ()
runWithMemory :: (forall s. State (Memory s v) a) -> aforall s says that the action must work for a fresh scope s chosen inside runWithMemory. Code outside the scope cannot provide a Var s, and the result type a cannot depend on s; otherwise it would not be independent of the universal quantifier. Returning a scoped variable is therefore rejected by the type.ST boundary: local mutation can happen inside the state thread, but a value whose type still mentions that private thread cannot escape. The source uses the example to make a broader point: the safety does not come from a programmer remembering every alias. It comes from denying both the library and its caller the type-level information needed to leak the resource. 1cachedUpdate
:: Eq a
=> (a -> IO ())
-> IORef a
-> a
-> IO BoolcachedUpdate is parametric in a apart from equality, it cannot edit a Config, invent a different Config, or decide that one field should be treated specially. That policy stays with deployConfig or its caller.[User] -> Text -> IO () can inspect users, skip some, vary messages, or log personal data. A function shaped like this is more constrained:notifyAll
:: Foldable t
=> t recipient
-> (recipient -> IO ())
-> IO ()notifyAll cannot decide who counts as an administrator or inspect a recipient's fields. It may choose a fixed structural subset, but any user-specific policy has to live in the callback or the caller. The type makes the boundary visible in a place where a code review can inspect it.seq, unsafe operations, runtime reflection through typeclass dictionaries, and other language features complicate the simple “only one implementation” story. The source explicitly brackets off several of these cases rather than pretending they do not exist. 1Show a permits observation through show; Ord a permits comparison. Parametricity limits the function to the operations in scope; it does not make constrained code parametric in the unconstrained sense.a -> a can still diverge under Haskell's semantics, and a function can obey its type while doing an unhelpful permutation.“The power of theforallto elect or reprobate instantiations and implementations through parametric polymorphism.”
“Basically: treat all monomorphic code with suspicion.”
Config needs a concrete or constrained type. The useful version of Le's advice is narrower: when code does not need the extra information, do not grant it by accident. 1ST example shows one answer already exists for a particular kind of local state. The harder design question is how much of the same discipline can survive at the boundary where code starts doing things in the world.haskell.org
This story was produced automatically by a channel. One sentence is all it takes for Neodrop to keep producing for you.