PureScript Row Polymorphism: Type-Safe Records Without Wrapper Types
PureScript's row polymorphism lets functions accept any record containing at least the fields they need — no interfaces or wrappers. A worked example, the Cons/Union machinery, and the pitfalls.
02 Jul 2025, 18:34 UTC

PureScript lets you write a function that accepts any record containing at least the fields it needs, checked at compile time, with no interface, adapter, or wrapper type. The mechanism is row polymorphism: records are built from rows of label-to-type mappings, and a function can quantify over "the rest of the row" so callers pass larger records freely.
Records are rows underneath
The familiar record literal { name :: String, age :: Int } is syntactic sugar for Record ( name :: String, age :: Int ). The part in parentheses is a row: an unordered mapping from labels to types. Because rows are unordered, { name :: String, age :: Int } and { age :: Int, name :: String } are the same type. There is no nominal record type and no constructor to import; the structure itself is the type.
The useful pattern: open rows
A closed record type says "exactly these fields." An open row says "at least these fields":
greet :: forall r. { name :: String | r } -> String
greet p = "Hello " <> p.name
The | r tail means "and whatever other fields the caller's record has." Both of these compile:
greet { name: "Ada" } -- r is the empty row
greet { name: "Ada", age: 36, admin: true } -- r is ( age :: Int, admin :: Boolean )
Removing name from the argument fails to compile. You get the decoupling that interfaces or structural subtyping provide in other languages, but the contract is just the function's type signature. A module that only reads name never needs to know about age, and refactoring a field rename is caught across every consumer at compile time.
The same open-row idea powers PureScript's effect tracking: rows describe which effects a computation may perform, so the technique you learn for records transfers directly.
Modifying rows: Cons, Union, and Nub
Reading fields needs no constraints. Building or transforming records generically does. The compiler uses type classes from the prim and purescript-record packages to prove row operations are valid:
Consproves a label with a given type can be added to (or is present in) a row, producing an extended row.Unionproves two rows combine into a third, used byRecord.merge.Nubproves a row has no duplicate labels, which matters because rows are multisets until deduplicated.
For example, Record.insert carries a Cons constraint so the result type statically includes the new field. You rarely write these constraints by hand in application code, but you will see them in error messages, so it helps to know what they assert.
Verifying the behavior yourself
In a scratch Spago project (any recent compiler; check your spago.yaml for the package set version), add to src/Main.purs:
greet :: forall r. { name :: String | r } -> String
greet p = "Hello " <> p.name
main = do
log (greet { name: "Ada", age: 36 })
log (greet { name: "Grace" })
Run spago build from the project root; it should compile. Then change the first call to greet { age: 36 } and rebuild: you should get a type error about a missing name label. This takes a few minutes and also shows you the actual error text your team would live with.
Limits and common mistakes
- Verbose errors. Row type mismatches print entire rows, and labels may appear in a different order than you declared them, which confuses newcomers comparing types visually. Putting explicit signatures at module boundaries keeps errors localized instead of cascading.
- Closed records hurt forward compatibility. Records are not runtime-extensible like JavaScript objects; adding a field changes the type. If an API takes a closed record, every new field is a breaking change for callers constructing values. Accept
{ ... | r }when the function only needs some fields. - Compile-time cost. Heavy generic programming with
Union/Nubconstraints can noticeably slow compilation in large projects. Prefer plain open-row parameters over deeply generic record combinators unless you need them. - API drift. The names and locations of row constraint classes have changed across compiler releases. Confirm the current
Cons/UnionAPI against your package set'spurescript-recordsource before writing generic record code.
The practical takeaway: default to open rows in function signatures, keep closed records for data you construct and fully own, and add explicit signatures at boundaries so row errors stay readable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.