Handling Evolving Data Structures with PureScript Row Polymorphism
Learn how PureScript's row polymorphism enables flexible, structural typing for records, allowing functions to operate on data structures without rigid type definitions.
18 Jan 2026, 08:08 UTC

The Rigid Record Problem
In many strongly typed languages, passing a record to a function requires that record to match a specific, predefined type. If you have a User type with five fields, but a function only needs the email field, you still have to pass the entire User object. If you later introduce a AdminUser with extra permissions, you often have to rewrite your functions or create complex interface hierarchies to allow both types to be processed by the same logic.
PureScript solves this using Row Polymorphism. Instead of requiring a rigid type, you can define functions that operate on any record that contains at least a specific set of fields, regardless of what other data is attached.
Structural Typing via Row Constraints
Row polymorphism allows you to treat records as collections of labeled fields (rows) rather than monolithic types. When you define a function with a row constraint, you are telling the compiler: "I don't care what this record is, as long as it has these specific keys with these specific types."
This is a form of structural typing. Unlike nominal typing (where a type is defined by its name, like class User), structural typing is defined by the shape of the data. This eliminates the need for boilerplate wrapper classes or "Adapter" patterns common in object-oriented languages.
Practical Implementation: Generic Data Processors
To implement this, you use a type variable (usually denoted as r) to represent the "rest" of the record. This allows the function to accept any record that satisfies the required fields while preserving the types of the extra fields for the caller.
-- Required PureScript version: 0.15+
-- This function works on any record containing an 'email' string
sendWelcomeEmail :: forall r. { email :: String | r } -> Effect Unit
sendWelcomeEmail user = do
log ("Sending email to: " <> user.email)
In the example above, { email :: String | r } defines a record that must have an email field of type String, and | r represents an open row of any other fields. This means you can pass a minimal record, a full User record, or a SystemConfig record, provided they all have an email key.
Comparison: Rigid vs. Polymorphic Records
| Feature | Rigid Records (Nominal) | Row Polymorphism (Structural) |
|---|---|---|
| Type Definition | type User = { name :: String, age :: Int } |
{ name :: String | r } |
| Flexibility | Must match exactly or inherit | Must contain minimum required fields |
| Boilerplate | High (requires many type aliases) | Low (generic constraints) |
| Compiler Check | Fast, simple name check | More complex structural analysis |
Trade-offs and Compiler Behavior
While row polymorphism increases reusability, it introduces specific engineering trade-offs:
- Error Message Complexity: When a row constraint is not met, the compiler errors can become verbose. You may see messages referring to
Rowtypes that look like internal compiler representations rather than your own type aliases. - Build Times: The type checker must perform more work to unify open rows across function calls, which can lead to slower compilation in very large projects with deeply nested polymorphic records.
- Loss of Strictness: If every function uses open rows (
| r), you lose the ability to guarantee that a record only contains specific fields. This can be problematic when serializing data to JSON where unexpected fields might cause API errors.
Verifying Your Implementation
To verify that your row polymorphism is working correctly, attempt to pass three different record shapes to your function:
- A record containing only the required fields.
- A record containing the required fields plus additional fields.
- A record missing one of the required fields (this should trigger a compile-time error).
If the first two compile and the third fails, your structural constraint is correctly implemented. To revert a change to a polymorphic record, simply replace the open row | r with a concrete record type definition.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.