Row Polymorphism: Transitioning from Nominal Type Constraints to Extensible Records
0 reputation · 23 Sept 2020, 22:06 UTC
0 reputation · 23 Sept 2020, 22:06 UTC
PureScript implements row polymorphism to allow functions to operate on records containing at least a specific set of fields. This provides a flexible alternative to nominal type systems, enabling greater extensibility in data structures.
When transitioning a codebase from a nominal approach to one utilizing row polymorphism, there is a design boundary regarding how to constrain these records without sacrificing the ability to add arbitrary fields. Specifically, the interaction between row variables and strict record types can create ambiguity in type inference during complex transformations.
What is the recommended pattern for defining row constraints that ensure a minimum set of fields while preventing the accidental introduction of incompatible types? How does the compiler handle the resolution of these rows when nested within higher-kinded types?
The recommended pattern for ensuring a minimum set of fields while maintaining extensibility is the use of row variables. Instead of defining a strict record type, you define a type signature that requires specific fields and a variable representing the "rest" of the record. This prevents the type system from collapsing the record into a fixed nominal type, which would otherwise strip away any additional fields passed to the function.
In a row-polymorphic system, a constraint is typically expressed as: { field1 :: Type1, field2 :: Type2 | r }. Here, r is the row variable. By returning the same row variable r in the output, the function guarantees that any fields present in the input record—even those the function does not know about—are preserved in the output.
To prevent the accidental introduction of incompatible types or field collisions, use row subtraction or explicit row constraints. If a function must add a field that might already exist, the type signature should explicitly exclude that field from the row variable r to avoid ambiguity during unification.
When row-polymorphic records are nested within higher-kinded types (such as Functor or Monad), the compiler handles resolution through unification. The row variable is treated as a type-level parameter. When the higher-kinded type is applied, the compiler attempts to unify the provided record's row with the constraint's row variable.
If the record is wrapped in a container (e.g., Maybe { x :: Int | r }), the compiler maintains the row variable r inside the container's type parameter. This ensures that the extensibility is preserved even when the record is transformed via mapping functions, as the transformation operates on the value while the type system tracks the row variable across the functorial boundary.
To verify that your constraints are correctly implemented and not "slicing" the record, test with a record containing an unexpected field:
-- Expected: { x: 1, z: "extra" } -> { x: 1, y: 2, z: "extra" }
-- Failure: { x: 1, y: 2 } (The 'z' field was lost)
Note: This analysis assumes a compiler implementation similar to PureScript or OCaml. If you are using a custom type-erasure process, please specify if your records are implemented as fixed-offset structures or hash-maps, as this affects the runtime cost of row resolution.
Use comments to ask for clarification. Post a solution as an answer.
6,120 reputation · 24 Sept 2020, 03:52 UTC
When PureScript compiles a row‑polymorphic record, the row variable r is erased; the resulting JavaScript value is just a plain object containing the concrete fields that were present at the call site. The compiler uses the variable only to track extensibility during type checking, so nesting the record inside a higher‑kinded type such as Maybe, Array, or a custom functor does not add any runtime overhead—the container holds the same object, and any extra fields survive the transformation unchanged.