PureScript Row Polymorphism: Flexible State Without Boilerplate
PureScript's row polymorphism lets you write functions that accept any record with a required set of fields, preserving extra fields automatically. This blog shows a concrete updateName utility, explains the trade-offs, and gives a verification step you can run today.
01 Sept 2025, 08:12 UTC

The Problem: Rigid Records Force Nesting or Duplication
In a typical PureScript application, you might model user state as a record with fields like name, email, and preferences. As features grow, different parts of the app need slightly different views of that state — perhaps a profile editor needs name and avatar, while a notification module only cares about email and optIn. Without a way to express "at least these fields," you end up either nesting records deeply or duplicating update logic for each concrete type.
Row Polymorphism Lets Functions Accept Extra Fields
PureScript’s row polymorphism solves this by introducing row variables (commonly named r) that stand for "the rest of the record." A function signature like forall r. { name :: String | r } -> { name :: String | r } means: "give me any record that has a name field of type String, and I’ll return a record with the same shape." The caller can pass a record with additional fields — email, age, settings — and the type checker guarantees they’re preserved untouched.
This is structural typing: compatibility is determined by the presence of required labels, not by a declared interface or class hierarchy. It’s the same principle that makes TypeScript’s structural types feel familiar, but PureScript makes it first-class and composable with type classes.
Worked Example: A Generic updateName Utility
Create a file src/StateUtils.purs in a Spago project (PureScript 0.15+, Spago 0.20+). The function below updates the name field while leaving all other fields intact.
module StateUtils where
import Prelude
import Data.Record (modify)
updateName ::
forall r.
{ name :: String | r }
-> String
-> { name :: String | r }
updateName record newName =
modify #name (const newName) record
Now use it in src/Main.purs with two different record shapes:
module Main where
import Prelude
import Effect.Console (logShow)
import StateUtils (updateName)
type UserProfile =
{ name :: String
, email :: String
, avatar :: Maybe String
}
type MinimalUser =
{ name :: String
, id :: Int
}
main = do
let profile : UserProfile =
{ name: "Alex"
, email: "[contact removed]"
, avatar: Nothing
}
let minimal : MinimalUser =
{ name: "Sam"
, id: 42
}
logShow $ updateName profile "Alexandra"
logShow $ updateName minimal "Samuel"
Run spago build from the project root (requires write access to output/ and .spago/). The compiler accepts both calls because each record contains name :: String. The returned records keep email, avatar, and id unchanged. No casting, no wrapper types, no inheritance.
Trade-offs: Verbose Errors and Learning Curve
Row polymorphism shines for utility functions that operate on a common subset of fields — think lenses, serialization, or state transformers. However, type errors involving row variables can span dozens of lines, listing every field in the row. For newcomers, this noise obscures the actual mismatch. Mitigation: keep row-polymorphic functions small and well-named; use type synonyms to document expected shapes.
Compile-time performance is generally fine, but projects with hundreds of heavily row-polymorphic modules may see slower builds. If that becomes an issue, consider concrete types for hot paths and reserve row polymorphism for boundary layers.
Verify It Yourself
Add a third record missing name and watch the compiler reject it:
let broken = { age: 30 }
-- updateName broken "Test" -- Type error: no field 'name'
Run spago build again; the error will point to the missing label. This confirms the constraint is enforced at compile time.
Closing: Start Small, Scale Naturally
Row polymorphism isn’t a silver bullet, but it eliminates a whole class of boilerplate when you need flexible, type-safe record manipulation. Introduce it in a single utility module — like the updateName example — and expand as you discover more shared field patterns. Your state structures stay flat, your functions stay generic, and the compiler keeps you honest.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.