Row polymorphism
TL;DR. The type
{ x: Int | r }— "a record with at leastx: Int, whererstands for the rest" — is part of the grammar and parses today. It does not yet constrain anything: a record withoutxis accepted where one is required. The expression-level half of the design (anonymous record literals, the pipe-splat, lacks predicates) is not implemented at all.
Every row in the table at the end of this page was measured with
verum check on 2026-09-03, each probe differing from its control in one
value. Three things this page previously called Stable do not work.
Read the table before building on the feature.
The idea
A closed record type { x: Int, y: Int } names exactly two fields. An
open one, { x: Int | r }, is meant to say "at least x: Int; r
captures whatever else is there", so one function serves every record
that carries the fields it actually reads:
fn get_x<r>(p: { x: Int | r }) -> Int { p.x }
That is the design. What follows is where the implementation stands.
What parses and works
Open record types in a signature. grammar/verum.ebnf:1403 gives
record_type = '{' , [ field_list ] , [ '|' , identifier ] , '}' ;
so { x: Float, y: Float | r } is grammatical in any type position, and
the parser builds it (TypeKind::Record { fields, row_var }). A function
declared that way compiles, and a Point passed to it is accepted.
Note the grammar admits one row variable, not a list: { x: Int | r, s }
is not the language.
Record update, with the comma spelling. A functional update exists —
field_inits ends with an optional '..' , expression:
type P is { a: Int, b: Int };
let base = P { a: 1, b: 2 };
let plus = P { a: 5, ..base }; // ok: b comes from base
It requires the named type in front and a comma before ...
What does not work yet
The open record type constrains nothing. Both of these are accepted:
type Point is { x: Float, y: Float };
type Only is { z: Float };
fn dist<r>(p: { x: Float, y: Float | r }) -> Float { p.x }
dist(Point { x: 3.0, y: 4.0 }); // accepted — correct
dist(Only { z: 1.0 }); // ALSO accepted — should be refused
The second call passes a record that has neither x nor y. The row type
is currently syntax without a check, so it documents intent rather than
enforcing it.
Anonymous record literals do not exist. record_expr = path , '{' , field_inits , '}' requires a named type, so
let base = { a: 1, b: 2 }; // error<E100>: unbound variable: a
the braces are parsed as a block, and a as an expression.
The pipe-splat is not the update syntax — and it silently means
something else. This page used to show { c: 3 | ..base }. With a named
type in front it parses, but as a bitwise or of 3 with the range
..base:
let plus = P { a: 5 | ..base };
// error<E400>: Type mismatch in field 'a' of 'P':
// expected 'Range<P>', found 'Int'
Use the comma spelling above.
Lacks predicates are a parse error. where r # y — the constraint
this page described as threaded through inference — is not in the grammar
and not in the parser:
fn rename<r>(p: { x: Int | r }) -> Int where r # y { p.x }
// error<E032>: Parse error: expected `{` to start function body
Consequently the error message this page quoted (row `r` must lack field `x` ) is not one the compiler can produce.
Status
| Feature | Status | Measured |
|---|---|---|
Open record type parses ({ x: T | r }) | Works | in a signature, accepted |
| Open record type checks | Not implemented | a record lacking the named fields is accepted |
Record update P { a: 5, ..base } | Works | 0 errors |
Anonymous record literal { a: 1 } | Not implemented | E100 — parsed as a block |
Pipe-splat { a: 5 | ..base } | Not implemented | parses as bit-or with a range |
Lacks predicate where r # y | Not implemented | E032 parse error |
Two row variables { x: T | r, s } | Not in the grammar | one row variable only |
| Row-based protocol dispatch | Planned | — |
See also
- Types — records and their closed form.
- Generics — type parameters.
- Grammar reference — Types — the
formal
record_typeproduction, which is the authority here.