A function that reads a user’s name should not care whether the same record also carries an identifier, locale, permissions, or cached preferences. A command handler that knows how to process Create should be reusable even when another module extends the command set with Archive. Closed record and variant types make those extra labels part of a fixed declaration, so reuse often depends on subtyping, inheritance, wrappers, or rebuilding values.
Row polymorphism provides another model. A record or variant consists of a known fragment plus a row variable standing for the labels not mentioned. A function can require name: String while preserving an unknown remainder r. Another function can handle one variant case and return the unhandled remainder. Openness is expressed in the type relationship rather than hidden in dynamic maps.
The thesis is that row polymorphism abstracts over the shape surrounding known fields or cases. It is not ordinary polymorphism over one field’s value, and it is not simply a permissive object type. Useful row systems track label presence, absence, and preservation so extension and restriction remain unambiguous.
See a Row as a Typed Collection of Labels
A conventional closed record lists its complete shape:
User = { id: UserId, name: String, locale: Locale }
An open record leaves a remainder:
{ name: String | r }
Read this as “a record with a name field of type String, plus every field described by row r.” The separator is notation, not a runtime operation. If r = { id: UserId, locale: Locale }, substituting it produces the closed User shape.
Rows have their own kind. A simplified kind assignment is:
String : Type
{ name: String | r } : Type
r : Row
The row is not itself a runtime record. It is type-level information mapping labels to their field types, sometimes also tracking whether labels are present or absent. Labels are normally unique, so { name: String, name: Int } needs either rejection or an explicit shadowing rule. Silent ambiguity would make selection meaningless.
A field reader can quantify over the unknown row:
getName : forall r. { name: String | r } -> String
getName record = record.name
This says more than accepting a dictionary of unknown values. The name field is statically known to exist and be a String; other fields retain their individual types even though getName never inspects them. It also says less than a named User interface: any record with the required fragment is admitted.
Row order should not matter. { id: Id, name: String } and { name: String, id: Id } describe the same record shape in most row systems. Type inference therefore unifies rows modulo label ordering rather than treating them as positional tuples.
Preserve the Unknown Remainder Through Functions
The row variable matters most when it appears in both input and output. A function that normalizes a name can promise to preserve every unmentioned field:
normalizeName : forall r.
{ name: String | r } -> { name: String | r }
At a call with { name: String, id: UserId, locale: Locale }, inference solves r as { id: UserId, locale: Locale }. The result retains those fields with their original types. The implementation cannot legitimately drop id while satisfying the signature, assuming the language’s row abstraction is sound.
Compare three superficially similar contracts:
| Contract | What is known about extras | What the result preserves |
|---|---|---|
{ name: String } as an exact record |
No extra fields allowed | Only name |
{ name: String } under width subtyping |
Extras may exist in the value | Often only the static supertype is visible |
| `{ name: String | r } -> { name: String | r }` |
Width subtyping says a larger record may be used where fewer fields are required. That is valuable, but subsumption can forget the larger shape. Row polymorphism names the remainder so later types can refer to it. Some languages combine both mechanisms; they still answer different questions.
Ordinary generics over a whole type also fall short. A parameter T can preserve an input type as T -> T, but it cannot by itself state that T must contain name: String and that the implementation may update exactly that field. Row constraints expose the required fragment while retaining the rest.
This is structural abstraction rather than nominal inheritance. Records do not need to declare that they implement a Named base type. Compatibility follows from labels and field types, while a row variable carries precise residual shape through composition.
Extend and Restrict Rows With Explicit Constraints
Adding a field seems simple:
withTrace : forall r.
{ | r } -> { traceId: TraceId | r }
But what if r already contains traceId? The type needs a policy. A lacks constraint states that the label is absent:
withTrace : forall r. Lacks<traceId, r> =>
{ | r } -> { traceId: TraceId | r }
Now extension cannot create a duplicate. If replacement is intended, give it a different operation whose input requires the old label and whose output records the new type:
replaceTrace : forall old r.
{ traceId: old | r } -> { traceId: TraceId | r }
Record restriction removes a known label and returns its value plus the remainder:
takeSecret : forall secret r.
{ secret: secret | r } -> (secret, { | r })
Selection, extension, replacement, and restriction have different semantics. A row-aware API should not overload one spread operator and hope inference reconstructs intent. In particular, unrestricted right-biased merge can silently change field types when labels overlap.
Row concatenation requires a disjointness condition if duplicate labels are forbidden:
merge : forall left right.
Disjoint<left, right> =>
{ | left } -> { | right } -> { | left + right }
Some systems use presence and absence markers inside rows rather than separate constraints. Others support scoped labels or duplicate labels with deterministic shadowing. The notation varies, but every design must answer whether a label exists, whether it may be introduced, and which occurrence selection finds.
Work Through a Request-Enrichment Pipeline
Middleware often accumulates facts. Parsing supplies a route, correlation adds a trace identifier, authentication adds an actor, and authorization adds a permission decision. A dictionary such as Map<String, unknown> is flexible but loses the dependencies between stages. Rows can type those dependencies while allowing unrelated context to pass through.
correlate : forall r. Lacks<traceId, r> =>
{ headers: Headers | r }
-> { headers: Headers, traceId: TraceId | r }
authenticate : forall r. Lacks<actor, r> =>
{ headers: Headers | r }
-> Result<{ headers: Headers, actor: Actor | r }, AuthError>
authorize : forall r. Lacks<permission, r> =>
{ actor: Actor, route: Route | r }
-> Result<{
actor: Actor,
route: Route,
permission: Permission,
| r
}, AuthorizationError>
Begin with this context:
incoming : {
headers: Headers,
route: Route,
receivedAt: Instant
}
Applying correlate solves r as { route: Route, receivedAt: Instant } and returns:
{
headers: Headers,
traceId: TraceId,
route: Route,
receivedAt: Instant
}
On the success branch, authenticate captures { traceId, route, receivedAt } in its row and adds actor. authorize then sees both required fields, actor and route, while its remainder contains headers, traceId, and receivedAt. The final success type preserves all prior context and adds permission.
Reordering authorize before authenticate fails statically because the input row lacks actor. Running correlate twice fails because its second input violates Lacks<traceId, r>. A logging function can require only { traceId: TraceId | r }, so it composes at any later stage without knowing the complete pipeline shape.
The row says nothing about whether authentication is secure or whether a header can be trusted. Constructors for Actor and Permission must still enforce runtime validation. The benefit is structural: once a stage has produced trusted values, later stages can declare exactly which labels they require and preserve every unrelated label without defining a new nominal context type for each combination.
Open Variants Reverse the Record Perspective
Records are products: a value contains all listed fields. Variants are alternatives: a value contains one labeled case. A closed variant might be:
Command = [ Create: CreateInput | Cancel: CancelInput ]
An open variant includes a row variable for additional cases:
[ Create: CreateInput | r ]
For records, adding labels gives a value more fields. For variants, adding labels gives a value more possible cases. This reverses several intuitions. A record consumer can operate when required fields are present; a variant consumer must account for every case it may receive or explicitly return the unhandled remainder.
A handler for one case can narrow an open variant:
handleCreate : forall r.
[ Create: CreateInput | r ]
-> [ Created: Order | r ]
If the input is Create, the function produces Created. Any alternative in r passes through unchanged. This style supports composable handlers: a cancellation handler can process Cancel without knowing about future archive commands, and a chain eventually closes the row before execution reaches a boundary that requires exhaustive handling.
Another design returns either the handled result or the residual variant explicitly:
tryCreate : forall r.
[ Create: CreateInput | r ]
-> Either<Order, [ | r ]>
When the value is not Create, the result’s right side proves that case has been removed from the set of possibilities. Repeating this operation can shrink a variant row until no alternatives remain. An impossible empty variant can then justify exhaustiveness.
Open variants are useful for extensible interpreters, plugin messages, and modular error sets, but openness moves compatibility work to boundaries. A persisted message reader still needs a policy for unknown labels. Static extensibility inside one build does not make an older deployed binary understand a newly emitted command.
Let Unification Infer Rows Without Hiding Public Intent
Row inference solves equations involving known labels and unknown remainders. Applying getName to { id: UserId, name: String } produces an equation equivalent to:
{ name: String | r } ~ { id: UserId, name: String }
The solution is r = { id: UserId }. Because label order is irrelevant, the unifier must match by label and solve the residual row. Lacks constraints add negative information: solving withTrace also requires proving that traceId is not in the remainder.
Principal types are desirable. The inferred type for getName should be the most general contract from which more specific uses follow, not a type tied to the first record seen. Duplicate labels, row subtraction, optional fields, subtyping, and overloaded labels can make principal inference difficult or impossible in some systems.
Public annotations are still valuable. A middleware inferred as preserving r communicates a stable promise. If an implementation accidentally reconstructs only the fields it reads, an explicit output row catches the loss. An inferred monomorphic object type might otherwise compile while weakening composition.
Error messages should show missing or conflicting labels in domain terms. Raw unification output involving nested row variables is difficult to act on. Libraries can name common fragments such as Authenticated<r> = { actor: Actor | r } while avoiding aliases for every intermediate pipeline state.
Compare Rows With Nearby Object Models
Structural object systems can approximate parts of row polymorphism through intersections, mapped object operations, spreads, and generic constraints. They may be perfectly adequate for local APIs, but details matter: excess-property checks may apply only to literals, index signatures can weaken known fields, intersections with conflicting labels can produce unusable types, and a spread’s overwrite order may not match a clean disjoint union.
Nominal interfaces provide stable names, documentation ownership, and intentional conformance. They are often better for public domain entities. Rows excel for transformations that preserve or refine anonymous structure, especially pipelines and record combinators.
Database row types and schema inference are a natural application, but runtime query results still require trustworthy drivers or decoders. A static type for { id, total | r } cannot prove that a dynamic column alias exists. Likewise, JSON objects need validation before being introduced as typed open records.
Row polymorphism also differs from optional fields. { locale: Option<Locale> | r } requires a locale label whose value may be absent. A row where locale itself may be absent models a different operation: selecting the label can be invalid unless a presence constraint is established. Conflating the two produces subtle update and serialization bugs.
Manage Failure Modes and API Tradeoffs
Rows reduce nominal boilerplate and preserve exact extra fields through generic transformations. They support modular composition and can make required pipeline dependencies visible. The price is a more sophisticated type solver and less obvious errors when labels overlap or inference loses principal types.
Common failures include:
- Using an unconstrained extension operation that can introduce duplicate labels.
- Treating replacement as disjoint extension and changing a field type accidentally.
- Forgetting that open variants require an unknown-case policy at versioned boundaries.
- Reconstructing a record from known fields despite promising to preserve row
r. - Allowing a dynamic string-keyed map to masquerade as a statically tracked row.
- Exposing enormous inferred rows in public diagnostics instead of naming stable fragments.
- Assuming structural presence proves semantic validity or authorization.
Rows can also overfit incidental data flow. If every function adds one temporary diagnostic field, inferred contexts become hard to understand and retain data longer than intended. Restrict fields deliberately at boundaries and avoid carrying secrets merely because preserving r is convenient.
Choose closed named records when the complete schema is a domain contract, when nominal identity matters, or when serialization compatibility needs one owner. Choose open rows when a function genuinely operates on a fragment and should preserve unknown structure. The two styles compose: a named record can be the known fragment inside a larger open context.
Verify Shape Laws and Boundary Behavior
Compile-time fixtures should establish both accepted and rejected compositions. Verify that normalizeName preserves an unrelated branded identifier type, authorize cannot run without actor, withTrace rejects a preexisting trace label, and disjoint merge rejects conflicting field names. For variants, prove that each handler removes exactly its case and that the final closed dispatcher is exhaustive.
Property tests can check record-operation laws over generated values:
- Selecting a newly extended field returns the inserted value.
- Restricting a fresh extension returns the original remainder unchanged.
- Disjoint merge preserves every field from both inputs.
- Normalizing one field does not alter unrelated fields.
- Passing an unhandled open variant through a handler preserves its label and payload.
Runtime boundary tests decode JSON, database rows, and messages with missing, extra, wrongly typed, and unknown labels. Decide whether extras are retained, ignored, or rejected, then make that policy agree with the static API. Version tests should confirm that older readers fail safely or route unknown variant labels rather than misclassifying them.
For the request pipeline, integration tests should exercise every short-circuit error and assert that successful stages retain traceId, receivedAt, and other context exactly once. Log the stage and stable field names, but do not serialize the entire open record indiscriminately; an unknown row may contain credentials or personal data.
Row polymorphism is powerful because it lets code know less without forgetting what callers know. A function focuses on a required fragment, a row variable preserves the remainder, and constraints make extension or removal precise. That combination gives open records and variants a static structure: extensible, composable, and still explicit about the labels on which correctness depends.