Implementing Extensible Data Pipelines with PureScript Row Polymorphism
Learn how to use PureScript's row polymorphism to create extensible record types, ensuring data pipelines preserve extra fields while maintaining strict compile-time type safety.
30 Mar 2026, 18:03 UTC

The Problem: Data Loss in Rigid Record Types
In many statically typed languages, passing a record (or object) into a function requires an exact type match. If you define a function to accept a User record with a name and email, passing a PremiumUser record that adds a subscriptionId often requires explicit casting or results in the loss of the extra fields when the record is returned. This forces developers to either create bloated types that anticipate every possible field or use unsafe type casting.
The takeaway: PureScript uses row polymorphism to allow functions to operate on records that contain at least a specific set of fields, preserving any additional data throughout a transformation pipeline without sacrificing compile-time safety.
Prerequisites
- PureScript compiler (
purs) installed. - Basic familiarity with PureScript's type system and the
spagopackage manager. - An understanding of records as key-value collections.
Defining Row Polymorphic Functions
To create a function that is agnostic to extra fields, you must use a row variable. A row variable (typically denoted as r) represents the "rest" of the record's fields.
Consider a scenario where you need to normalize user names across different data sources. Some sources provide only a name, while others provide names, IDs, and timestamps.
-- This function accepts any record that has at least a 'name' field of type String.
-- The 'r' captures all other fields present in the input record.
normalizeName :: forall r. { name :: String | r } -> { name :: String | r }
normalizeName user = user { name = trim user.name }
In this signature, { name :: String | r } tells the compiler: "I don't care what else is in this record, as long as there is a name field that is a String." Because the return type also uses r, the function guarantees that any extra fields passed in are preserved and returned unchanged.
Building a Type-Safe Transformation Pipeline
Row polymorphism becomes powerful when chaining multiple functions. Each function can request only the specific fields it needs to perform its task.
-- Requirement: Record must have 'age'
isAdult :: forall r. { age :: Int | r } -> Boolean
isAdult user = user.age >= 18
-- Requirement: Record must have 'name'
greet :: forall r. { name :: String | r } -> String
greet user = "Hello, " <> user.name
-- A pipeline that uses both
processUser :: forall r. { name :: String, age :: Int | r } -> String
processUser user =
if isAdult user
then greet user <> " (Adult)"
else greet user <> " (Minor)"
Comparison: Fixed Records vs. Row Polymorphism
| Feature | Fixed Record { name :: String } |
Row Polymorphic { name :: String | r } |
|---|---|---|
| Input Flexibility | Only accepts exactly name |
Accepts name + any other fields |
| Output Integrity | Returns only name |
Returns name + original extra fields |
| Type Safety | Strict | Strict (checked at compile time) |
Verification and Diagnostics
To verify your implementation, test the function against records of varying shapes. Use the purs compiler to ensure that constraints are enforced.
Success Case
Passing a record with extra fields should compile successfully:
-- This should compile because it satisfies { name :: String | r }
let user = { name: " Alice ", id: 101, city: "New York" } in normalizeName user
Failure Case (Type Error)
Attempting to access a field not explicitly defined in the row constraint will trigger a compiler error. This prevents runtime undefined errors common in JavaScript.
-- This will fail to compile
let user = { name: "Alice" } in
let normalized = normalizeName user
in normalized.id -- Error: Field 'id' not found in record type { name :: String }
Diagnostic Tip: If you encounter a "Type mismatch" error involving rows, check if you have accidentally omitted the | r variable in either the input or output signature. Without the row variable, PureScript treats the record as closed, meaning it must contain exactly those fields and no others.
Limitations and Considerations
- Error Verbosity: Complex row transformations (especially when using row-level operators like
#) can produce long, intimidating compiler messages. Break complex transformations into smaller, named functions to isolate errors. - Nested Updates: While row polymorphism handles top-level fields elegantly, updating deeply nested records can become syntactically heavy. For deeply nested data, consider using a lens library (like
purescript-generic-lens) to maintain readability. - Performance: There is negligible runtime overhead for row polymorphism as it is primarily a compile-time type-checking mechanism; the resulting JavaScript is standard object manipulation.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.