PureScript Row Polymorphism: Flexible Records Without Losing Type Safety
Row polymorphism lets PureScript functions accept records with extra fields while keeping static type safety. Learn the syntax, a worked example, and the limits.
08 Dec 2025, 00:16 UTC

The Problem: Rigid Record Types
In a strictly typed language, a function that expects a record with exactly two fields will reject a record that has three. This forces you to define a separate type for every shape of data or fall back to unsafe casts. PureScript solves this with row polymorphism: you can require that a record contains at least a specific set of fields while ignoring whatever else is present.
How Row Polymorphism Works
PureScript records are structurally typed, meaning compatibility is determined by the fields a record actually has rather than by a named type declaration. When you write a type like { name :: String | r }, the | r part is a row variable. It stands for the rest of the record—any other fields that might be attached. The forall r. in the signature tells the compiler that r is a placeholder to be filled in at each call site. This gives you the flexible shape-matching familiar from JavaScript, but the compiler still guarantees that every field you access is present.
Worked Example: Reading a Required Field
Suppose you need a function that extracts a username from any user record, regardless of whether it also contains an age, email, or address.
getUsername :: forall r. { username :: String | r } -> String
getUsername record = record.username
Because the row variable r absorbs the extra fields, both of these calls type-check:
user1 = { username: "alice" }
user2 = { username: "bob", age: 30, email: "[contact removed]" }
name1 = getUsername user1
name2 = getUsername user2
The function never touches age or email, so those fields can vary freely without breaking the type. If you later add a new field to user2, existing callers do not need to change.
Worked Example: Adding a Field
Row polymorphism also works when you return a record. PureScript's record update syntax creates a new record with the original fields plus the ones you specify. The output type uses the same row variable to preserve the unknown fields.
addTimestamp :: forall r. { | r } -> { timestamp :: Int | r }
addTimestamp record = record { timestamp = 1625000000 }
If the input has { username :: String }, the output is { username :: String, timestamp :: Int }. If the input has more fields, they are carried through unchanged. The row variable r ensures the function stays generic without collapsing every input into a single concrete record type.
Limits and Common Mistakes
Complex Type Signatures
Row variables are powerful, but they can make signatures harder to read. A function that manipulates several optional fields at once may end up with a long forall clause listing multiple row variables. When this happens, consider whether a nominal record type or a dedicated data structure would communicate intent more clearly. Row polymorphism is most useful at API boundaries where callers control the exact shape of the data.
Verbose Errors on Row Mismatches
When a required field is missing, the compiler reports a mismatch between the expected row and the actual row. With deeply nested records, these errors can span many lines and reference row variables by generated names, which makes them harder to debug than simple nominal type mismatches. Reading the error from the inside out—starting with the field names that differ—usually helps.
Accessing Fields Outside the Constraint
A frequent mistake is trying to read a field that exists in the row variable r but was never declared in the signature. For example, this will not compile:
printAge :: forall r. { username :: String | r } -> String
printAge record = show (record.age)
The field age is not guaranteed to exist, because r could be empty. To access it, you must add it to the required fields: { username :: String, age :: Int | r }.
Removing Fields
Restricting a record—removing a field while preserving the others—is possible but requires more advanced row constraints than simple reading or extension. In practice, this often means using a library such as purescript-record and its deletion functions, which rely on type-level symbols to prove the field exists before removing it. Because the exact API varies across compiler and library versions, verify the current signatures in your project's documentation before depending on them.
Compile-Time Cost
Row polymorphism is resolved at compile time, but records with many fields and frequent structural transformations can increase type-checking time. If you notice slow builds, look for functions that add or remove large numbers of fields in hot loops rather than at API boundaries.
Practical Verification
- Over-specify the input: Pass a record with several extra fields to a function constrained by one field. If it compiles, row polymorphism is working.
- Under-specify the input: Remove the required field and confirm the compiler rejects the call.
- Check the row variable scope: Ensure every row variable used in the signature appears in the
forallquantifier.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.