
`forall` chooses for you: a Haskell parametricity deep read
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.
The article at a glance
On August 4, 2026, Justin Le published "Five-Point Haskell": Unconditional Election (via Parametricity), a playful argument for treating Haskell's parametric polymorphism as a design tool rather than a type-system curiosity. The post is worth reading because it turns familiar signatures such as
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. 1The source's religious language is a joke. Its technical claim is more precise: when a function is genuinely polymorphic, it cannot inspect facts about a type that its signature does not give it. Those missing facts become guarantees for callers.
The argument, end to end
A type can remove choices before the function exists
Le starts with a comparison. In Java, a function written as
static <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 = xThe omitted
forall 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. 1The official GHC User's Guide makes the syntactic part explicit:
g :: 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. 2That distinction matters. Haskell is not magically proving that every program is pure or total. Le explicitly sets aside non-termination and escape hatches such as
unsafePerformIO and unsafeCoerce for this discussion. The guarantee belongs to the parametric fragment being discussed, not to every runtime behavior available in the language. 1Free theorems are the practical payoff
The post then turns type signatures into a guessing game. Consider:
mystery :: a -> b -> aThe second input cannot affect the result because its type
b is unrelated to the result type a. The function must return the first input. Or consider:theThing :: [a] -> [a]The function may reorder, duplicate, or discard elements, but it cannot inspect an element's value. It cannot sort by a property of
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.That fence gives a law that every such implementation must obey:
theThing . map f == map f . theThingMap first and then run
theThing, 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
The same reasoning works for other signatures in the post:
doIt :: [a] -> Maybe amay choose an element by position, but it cannot choose the smallest or largest element without anOrd aconstraint. It must returnNothingfor an empty list, and it commutes withmapandfmap.collapse :: [a] -> Intcannot use the list's contents, so its result can depend on the length or be constant.sumis ruled out;lengthis allowed.duper :: a -> [a]can only return copies of the input value.replicate nfits; a function that adds1to anIntdoes not, because it would fail for anothera.consumeInt :: forall r. (Int -> r) -> rmust apply the supplied continuation to one fixedInt. The result typeris arbitrary, so the function cannot manufacture anrby any other route.
These examples are the article's central teaching move. “Parametricity” can sound like a theorem from programming-language semantics. The guessing game makes it a code-review question: What information does this signature withhold, and what behavior does that rule out? Le points readers toward the free-theorem paper by Philip Wadler for the formal lineage. 1
More constraints can mean more useful code
Le's next step is deliberately counterintuitive. Developers often treat a more general type as a weaker statement: a function that accepts any
a appears to promise less than one that accepts Int. Here, the loss of information is what creates the promise.Suppose a business operation shuffles a list. These types permit different amounts of control:
[Int] -> [Int]
Num a => [a] -> [a]
Ord a => [a] -> [a]
[a] -> [a]The monomorphic version may inspect integers and manufacture new ones.
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.This is the article's version of the principle of least power. Choose the weakest type that can express the job, not because weaker code is morally superior, but because the type prevents unrelated policy from sneaking into the implementation. A function that only needs to rearrange a collection should not also have the authority to classify its elements.
The same idea appears at the higher-kinded level. Le contrasts:
traverseIO :: (a -> IO b) -> [a] -> IO [b]
traverse :: Applicative f => (a -> f b) -> [a] -> f [b]The second type is more abstract, and that abstraction carries a new boundary. The resulting effect can be built from the supplied
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.Add a type variable when the invariant belongs to one operation
The post becomes most useful when it stops talking about lists and applies the same reasoning to data design. Suppose a user ID must survive a function that updates other fields:
data User uid = User
{ userId :: uid
, userName :: String
, userAge :: Int
}
processUser :: User uid -> IO (User uid)The function may change the name and age, but it cannot replace the
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.Le uses the same trick for a checklist. If the container is abstracted as
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)This is not an argument for adding type parameters everywhere. Le is clear that the parameter is useful when it expresses a property the operation must preserve. A parameter added for decoration or hypothetical reuse does not buy the same guarantee.
The
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 fThe function's behavior is then constrained by the chosen
f. 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. 1The forall can close a resource scope
The strongest example is the one that connects the post to a familiar Haskell design:
ST.Le first sketches a memory store whose variables are identified only by integers. That version has a runtime problem. A variable created in an outer
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.The repair is to index both variables and memory by a fresh scope parameter:
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) -> aThe
forall 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.This is the same shape as Haskell's
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. 1What changes in day-to-day code
The article closes by applying the principle to ordinary application functions. Consider a cache around an expensive deployment:
cachedUpdate
:: Eq a
=> (a -> IO ())
-> IORef a
-> a
-> IO BoolThe implementation compares the old and new values, calls the supplied action when they differ, and writes the new value. Because
cachedUpdate 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.Le's warning here is sharper than “generic code is reusable.” If a new requirement says “deploy a default configuration when deployment fails,” the type should force a design decision. The function must now be able to obtain a default value, so its type or its inputs need to change. If a behavior that was previously impossible can be added without changing the contract, the original contract probably allowed more authority than the team realized. 1
His final example is a notification loop. A function typed as
[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.That is the post's durable engineering lesson. Parametricity is not a contest to maximize abstraction. It is a way to move invariants from comments and reviewer vigilance into the types that define the operation.
The caveats the joke needs
Le's theological metaphor makes the examples memorable, but it can also make the claim sound stronger than it is. Keep four limits in view:
- The reasoning assumes the ordinary parametric model. Bottom values, non-termination,
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. 1 - A typeclass constraint gives the function a capability.
Show apermits observation throughshow;Ord apermits comparison. Parametricity limits the function to the operations in scope; it does not make constrained code parametric in the unconstrained sense. - The guarantee concerns what an implementation can return or preserve, not whether the program is useful, fast, or total.
a -> acan still diverge under Haskell's semantics, and a function can obey its type while doing an unhelpful permutation. - Generalization has a cost. A type that excludes an unwanted policy may also exclude a policy the product genuinely needs. The right question is not “Can this be made more polymorphic?” but “Which information should this operation be allowed to use?”
A small checklist for using the idea
When reviewing a Haskell function or designing a new API, ask:
- Which type variables are truly arbitrary, and which capabilities are introduced by constraints?
- What values cannot the function manufacture because they would require information absent from the signature?
- What mapping, shape, length, ordering, or scope laws follow from that absence?
- Is a type parameter expressing an invariant for this operation, or is it only there for imagined reuse?
- Would a new business rule require a visible change to the type and its contract?
- Where do effects enter, and can a more general effect type prevent the function from adding work behind the caller's back?
Those questions are Le's “Unconditional Election” stripped of the sermon. Give a function less information, then inspect the guarantees that become unavoidable.
Lines worth keeping
“The power of theforallto elect or reprobate instantiations and implementations through parametric polymorphism.”
That is Le's definition of the post's title concept. It is theatrical, but it names the mechanism correctly: universal quantification rules out implementations that would need to distinguish among type instantiations. 1
“Basically: treat all monomorphic code with suspicion.”
This is the line to disagree with. Monomorphic code is not automatically bad; a function that must inspect a
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. 1Le ends by pointing toward the next problem: parametric guarantees are easiest in pure code, while real programs cross into effects. The
ST 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.References
- 1
- 2GHC User's Guide Documentation
haskell.org
This story was produced automatically by a channel. One sentence is all it takes for Neodrop to keep producing for you.
Related content
- Sign in to comment.
