Handling Dynamic Configurations with PureScript Row Polymorphism
PureScript row polymorphism allows for type-safe configuration merging. Learn how to use row constraints and Record.merge to ensure required keys exist at compile time.
09 Jul 2026, 17:51 UTC

Merging a set of default settings with environment-specific overrides is a common task, but in many languages, this is a runtime operation. A missing key or a type mismatch (e.g., expecting a number but receiving a string) often surfaces only when the application crashes in production. PureScript solves this by treating record merging as a compile-time operation using row polymorphism.
The Mechanics of Row Polymorphism
In PureScript, a record is not just a fixed structure; it can be described as an open set of labeled fields. A row type is written with known labels followed by a row variable, such as { host :: String | r }. The | r is the row constraint, which effectively means "this record must have a host of type String, and may contain any other fields described by the variable r."
This allows functions to be polymorphic over the "rest" of the record. When you pass a record into a function with a row constraint, the compiler tracks exactly which additional fields were present and ensures they are preserved in the return type, preventing the "type erasure" common in traditional object-oriented interfaces.
Implementing Type-Safe Config Merging
The most practical application of this feature is layering configuration. By using the purescript-record library, you can combine a base configuration with overrides while ensuring that the resulting record still satisfies your application's requirements.
Consider a scenario where a service requires a host and port, but optional timeout settings may be provided by the user:
-- Run these in a project with 'spago install record' installed
import Record as Record
-- Define a type alias for the required base configuration
type BaseConfig r = { host :: String, port :: Int | r }
-- Default values that satisfy the BaseConfig requirement
defaults :: BaseConfig ()
defaults = { host: "localhost", port: 8080 }
-- User overrides that might only contain a subset of fields
overrides :: { port :: Int, timeout :: Int }
overrides = { port: 3000, timeout: 5000 }
-- Record.merge combines the two. The result type is inferred as
-- { host :: String, port :: Int, timeout :: Int }
mergedConfig :: BaseConfig (timeout :: Int)
mergedConfig = Record.merge defaults overrides
In this example, Record.merge performs a type-level union. If overrides contained a port field of type String, the PureScript compiler would reject the code immediately because the labels must have matching types to be merged. Furthermore, because mergedConfig is typed as BaseConfig, any function expecting the base requirements can safely accept it.
Engineering Trade-offs
While row polymorphism provides strong guarantees, it introduces specific complexities:
- Error Verbosity: When a row constraint is not met (e.g., a required key is missing), the compiler errors can be dense. They often describe the unification failure of the row variables, which can be daunting for developers new to the language.
- Constraint Discipline: If you use open rows (
| r) everywhere without explicitly enforcing required keys at the boundaries, you effectively move toward a dynamically typed system, losing the benefits of the compiler. - Compiler Versioning: Row unification logic has evolved across PureScript versions. Code relying on highly complex row manipulations may require updates when upgrading the compiler to ensure unification still behaves as expected.
Verification and Validation
To verify your row-polymorphic logic, you can use spago build to check for type errors. A practical test for your configuration logic is to intentionally introduce a type mismatch in your override record (e.g., changing port :: Int to port :: String). The compiler should produce a type mismatch error specifically for that label.
To check if your required keys are being enforced, attempt to pass a record missing a head label (like host) into a function constrained by BaseConfig r. The build should fail, confirming that the row constraint is actively protecting your application boundary.
Actionable Summary
Use row-polymorphic records when you need to merge data structures—like API payloads or configs—where the set of fields is extensible but a core set of keys is mandatory. Define your required fields as the head of the record, use Record.merge for layering, and always enforce the BaseConfig r constraint at the entry point of your business logic to ensure runtime safety.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.