PureScript Open Rows for Type-Safe Record Enrichment
Use PureScript open row polymorphism to require specific record fields while preserving unknown fields through transformations, checked at compile time.
10 Aug 2025, 17:16 UTC

The Problem: Unknown Fields Disappear at the Boundary
When a PureScript application receives a record from an API request or a configuration file, handlers often need only a few fields. In a nominal type system, you must either define a strict type that drops every undeclared field or maintain a separate data-transfer object for each handler. Both approaches lose information: fields the handler does not know about are discarded, and downstream code cannot access them.
PureScript's row polymorphism offers a different boundary. An open row lets a function declare the fields it requires while treating every other field as an opaque payload that passes through unchanged. The compiler enforces the required fields at build time, so missing fields become compile errors instead of runtime `undefined` accesses.
Useful Takeaway
Use open row variables to write handlers that require specific fields and preserve all others. This removes manual mapping layers and keeps data loss errors out of production.
Requirements
- A function must require named fields, such as `email :: String`, on an input record.
- The function must return a record that contains every field from the input, even fields the function never touches.
- Calling the function with a record missing a required field must fail to compile.
Smallest Suitable Design
Define a row variable, conventionally named `r`, to stand for "the rest of the record." The function signature promises only that the input contains the required fields; it makes no claim about what else is present.
-- Require an email field; carry every other field forward unchanged.
requireEmail :: forall r. { email :: String | r } -> { email :: String | r }
requireEmail record = record
-- Update one field while preserving the rest of the row.
markVerified :: forall r. { verified :: Boolean | r } -> { verified :: Boolean | r }
markVerified record = record { verified = true }
In `{ email :: String | r }`, the vertical bar separates the known field from the row variable `r`. If `r` is `{ age :: Int, name :: String }`, the full input type is `{ email :: String, age :: Int, name :: String }`. The function does not need to know about `age` or `name`, but it must return them.
Trust and Data Boundaries
The trust boundary moves from runtime to compile time. Because `r` is universally quantified, the compiler rejects any input that lacks `email` or `verified`. It also guarantees that fields outside the constraint are not silently dropped.
| Concern | Nominal DTO approach | PureScript open row |
|---|---|---|
| Extra input fields | Dropped or require explicit mapping | Preserved through `r` |
| Missing required field | Runtime check or cast failure | Compile-time type error |
| Adding a new field | Update every DTO and mapping function | No change to row-polymorphic functions |
Operational Checks
- Missing field rejection: Pass `{ name: "Ada" }` to `requireEmail`. The compiler should report that the record lacks `email`.
- Extra field preservation: Pass `{ email: "[contact removed]", age: 30, role: "admin" }` to `markVerified`. Confirm the output still contains `age` and `role`.
- Composition: Chain `requireEmail` then `markVerified` on the same record. Verify the final type contains all original fields plus any updates.
To run these checks, open a PureScript project in your editor or terminal and run the build command, typically `spago build` from the project root. No special permissions are needed beyond read access to the source files. The expected result is a successful build for valid records and a type error for invalid ones. The risk is low because this is a compile-time check; it does not change deployed state.
Failure Modes and Limitations
- Complex error messages: Deeply nested or heavily constrained rows can produce long type errors. Trace the row variable through each function to find where the expected and actual rows diverge.
- Obscure intent: Heavy use of `r` can hide what the primary record shape is. Document common rows with type aliases, for example `type UserFields = { email :: String | r }`.
- Compile time: Very large records with many row constraints can slow type checking. This is usually only noticeable in large modules with dozens of fields.
When to Change the Design
- Exact schema required: If a record must contain only a fixed set of fields, use a closed record such as `{ email :: String }` instead of an open row.
- Dynamic keys: If field names are known only at runtime, use `Map String String` or a similar dictionary type rather than a record.
- Serialization boundaries: Row polymorphism is a compile-time construct. JSON decoding still requires explicit decoders; open rows do not eliminate runtime validation at the edge.
Practical Verification
Add the example functions to a module, compile with `spago build`, and inspect the error output for a deliberately incomplete record. If the build succeeds for complete records and fails for incomplete ones, the row boundary is working. No rollback is needed because compilation does not alter runtime state.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.