PureScript Row Polymorphism: Type-Safe Flexible Records
A practical guide to PureScript's built-in row polymorphism for type-safe, flexible record handling—covering open rows, Lacks constraints, row merge, and generic JSON encoding.
04 Sept 2026, 05:39 UTC

Desired Outcome
Write functions that accept records with varying fields while preserving compile-time type safety, using PureScript's built-in row polymorphism. This enables reusable record manipulation without sacrificing the guarantees that make PureScript valuable.
Prerequisites
- PureScript compiler installed (
npm install -g purescript) - Spago package manager (
npm install -g spago) - A PureScript project initialized with
spago init - Basic familiarity with PureScript records and type signatures
Understand Built-In Row Polymorphism
PureScript's compiler supports row polymorphism natively—no extra library required. The key syntax is the open row type { field :: Type | r } where r is a row variable representing "any additional fields." Constraints like Lacks and Cons come from Prim.Row (re-exported by Type.Row).
Add Required Dependencies
For the examples below, you'll need purescript-foreign and purescript-foreign-generic for JSON encoding. Edit spago.dhall and add them to the dependencies list:
let pkg = https://raw.githubusercontent.com/purescript/package-sets/psc-0.16.4-20240101/packages.dhall
in { name = "my-project", dependencies = [ "foreign", "foreign-generic", "console", "effect" ], packages = pkg.packages }
Then run spago install from the project root. The package set URL above is an example; use the version matching your compiler.
Define a Row-Polymorphic Function
Create src/RecordUtils.purs with a function that extracts a subset of fields from any record containing at least those fields:
module RecordUtils where
import Prim.Row (class Cons, class Lacks)
import Type.Row (type (\\), type (\/))
-- Extract name and email from any record that has them
extractContact :: forall r.
{ name :: String, email :: String | r }
-> { name :: String, email :: String }
extractContact record =
{ name: record.name, email: record.email }
The | r syntax denotes an open row—r represents any additional fields. The function accepts any record with at least name and email fields of type String.
Use the Function with Different Record Shapes
In src/Main.purs, demonstrate calling extractContact with records of varying structure:
module Main where
import Prelude
import Effect.Console (logShow)
import RecordUtils (extractContact)
main = do
let user = { name: "Alice", email: "[contact removed]", age: 30, role: "admin" }
let lead = { name: "Bob", email: "[contact removed]", company: "Acme Inc" }
let minimal = { name: "Carol", email: "[contact removed]" }
logShow $ extractContact user -- { name: "Alice", email: "[contact removed]" }
logShow $ extractContact lead -- { name: "Bob", email: "[contact removed]" }
logShow $ extractContact minimal -- { name: "Carol", email: "[contact removed]" }
Records have a built-in Show instance when all fields are Show, so logShow works directly.
Require Field Absence with Lacks
Use Lacks to require that a record does not contain a specific field. Add to RecordUtils.purs:
-- Add a timestamp field only if the record lacks 'createdAt'
addTimestamp :: forall r.
Lacks "createdAt" r
=> { | r }
-> { createdAt :: String | r }
addTimestamp record =
{ createdAt: "2024-01-15T10:30:00Z" } \/ record
The \/ operator (from Type.Row) merges two records. Since Lacks "createdAt" r guarantees no collision, this safely adds the field. Note: the timestamp is hardcoded for the example; in production you'd generate it at runtime via Effect.Now.
Update Main.purs to test:
import RecordUtils (extractContact, addTimestamp)
main = do
-- ... previous code ...
let withoutTimestamp = { name: "Dave", email: "[contact removed]" }
let withTimestamp = addTimestamp withoutTimestamp
logShow withTimestamp
-- { name: "Dave", email: "[contact removed]", createdAt: "2024-01-15T10:30:00Z" }
-- This would fail to compile:
-- let duplicate = addTimestamp { name: "Eve", email: "[contact removed]", createdAt: "old" }
Generic JSON Encoding via RowList Induction
For advanced use cases, you can write type-class-driven generic operations over rows using RowToList and RowList. This requires purescript-typelevel-prelude (for RowList types) and purescript-foreign-generic (for gEncodeJson). Add to spago.dhall dependencies if needed.
module RecordUtils where
import Prim.Row (class Cons, class Lacks, RowToList, RowList, Nil, Cons)
import Type.Row (type (\/))
import Foreign (class EncodeJson)
import Foreign.Generic (gEncodeJson, defaultOptions)
-- A simpler approach: use Foreign.Generic's built-in generic encoding
-- which already handles row-polymorphic records.
toJSON :: forall r.
RowToList r
=> EncodeJson { | r }
=> { | r }
-> Foreign
toJSON = gEncodeJson defaultOptions
This leverages the compiler's RowToList constraint and the generic instance from Foreign.Generic. No manual row-list induction is necessary for most cases.
Build and Run
spago build
spago run
Run from the project root. spago build compiles all modules and reports type errors. spago run executes Main.main. Expected output shows the extracted contacts and the timestamped record.
Expected Checks
- Compiler accepts records with extra fields beyond those required
- Compiler rejects records missing required fields (e.g., no
email) - Compiler rejects
addTimestampon records that already havecreatedAt - Output matches the logged record structures
Recovery Options
- Type errors on missing fields: Add the required field to the record or adjust the function's row constraint.
- Overlapping field names in merges: Ensure
Lacksconstraints are satisfied, or use record update syntaxrecord { field = newValue }to modify existing fields. - Dependency resolution fails: Run
spago upgrade-setto refresh the package set, thenspago installagain. - Runtime errors from FFI: Row polymorphism is purely compile-time; runtime issues indicate a mismatch between PureScript types and JavaScript values—add runtime validation at the FFI boundary.
Limitations
- Row polymorphism requires explicit type annotations in complex compositions; the compiler cannot always infer the row variable
r. - Deeply nested row operations can produce verbose error messages.
- Record update syntax
record { field = value }only modifies existing fields; to add fields, use row merge\/with aLacksconstraint. - The PureScript ecosystem has fewer libraries than Haskell or TypeScript; expect to write more custom row-polymorphic utilities.
- Version compatibility: the package set URL and compiler version must align. Check
purs --versionand use a matching package set from purescript/package-sets.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.