dcb1384b · core c3a5528b · built 2026-10-27 12:07 UTCRows as an Operator on Record Parameters
Status — under review (2026-10-24). design settlement committed to a release milestone (tracking issue linked).
Revised (2026-10-27). An earlier draft replaced
where all R: Awithwhere ..R: A. That is dropped:..stays an unpacking operator everywhere, and the field-wise constraint keeps a keyword, renamedalltoeach. Row declarations are this RFC's alone (RFC-0187 and RFC-0188 no longer touch them).
Status — accepted (2026-10-27). Open questions resolved 2026-10-27; keyword each
Status — integrated (2026-10-27). Spec integrated with Pending markers; implementation tracked in #1644
Status — implemented (2026-10-27). Implemented by metel-core#1644
Summary
Remove the row generic-parameter kind and rename the field-wise constraint keyword all to
each. A generic parameter that stands for a record is declared record R (as record T
already is), and the existing .. spread is the one way to get at a record's row: ..R yields
R's row. .. stays an unpacking operator everywhere (record tails, row arguments, spreads,
rest patterns, pack splices); the field-wise bound is a keyword, not a use of ...
// before // after
fun get_x<row R>(p: { x: f64, ..R }) -> f64 fun get_x<record R>(p: { x: f64, ..R }) -> f64
fun consume<row R>(r: { ..R }) where all R: Copy fun consume<record R>(r: { ..R }) where each R: Copy
extend<row R> { ..R }: Display where all R: Display extend<record R> { ..R }: Display where each R: Display
record Packet<row R> { data: { ..R } } record Packet<record R> { data: { ..R } }
fun f<row R>(p: Packet<..R>) -> ... fun f<record R>(p: Packet<..R>) -> ...
Two mechanical rewrites cover existing code: row becomes record at the binder, and all
becomes each in a field-wise bound (for rows and, in RFC-0125, for packs). Every use of ..R
is unchanged. row stops being a reserved word, and each replaces all as the contextual
keyword.
This amends RFC-0121 (open rows) and RFC-0123 (field-wise constraints), both implemented, and
renames the quantifier RFC-0125 reuses for packs. It is a breaking change to shipped syntax and
needs this RFC first (see metel_rfc_before_implementation).
Motivation
1. Two kinds for one idea
Today a generic parameter that stands for a record comes in two forms
(generic_param = (record_kw | row_kw)? ~ ident …):
record T(optionally: { x: f64, .. }): a type variable that is a record. The body can useTas a type:fun f<record T: { x: f64, .. }>(p: T).row R: a variable that is the rest of a record's fields. The body cannot nameRas a type; it can only write..Rinside a record type or a nominal type's argument list.
The spec's own map of the area ("Records and Rows at a Glance") lists them as rungs of a
ladder, but they differ only in what the variable may be written as. A row is the set of
labelled fields, and the spec says an anonymous record is its row, while a struct or
record declaration is a brand paired with a row. The second kind exists because the first has no way to say
"the fields of T", not because the two describe different things.
Measured against the current build: fun get_x<record R>(p: { x: i64, ..R }) is rejected
with T0012: R is not a row parameter; declare it <row R>, so the two kinds are not
interchangeable today even though both stand for a set of fields.
2. .. already means "the fields of" everywhere else
RFC-0178 added value-level .. forms that all read as "the fields of this record":
- a rest pattern
{ token, ..rest }binds the remaining fields; - a record-literal spread
{ ..rest, auth = a }places a record's fields into a new one.
The type level uses the same token ({ x: f64, ..R }), but with a different grammar:
.. followed by a row variable, which can only be declared with the special row binder.
If .. is an operator on a record, the value level and the type level say the same thing:
..r is the fields of the value r, and ..R is the fields of the type R.
3. A reserved word that does one job
row is a reserved word (grammar.pest's reserved list) used in exactly one place, the binder.
Once a record R parameter can stand for the rest of a record, the word has nothing left to do.
4. The constraint keeps a keyword, and a clearer one
The field-wise constraint (where all R: A) bounds R differently from where R: A: the record
as a whole versus each of its fields. The difference is carried by the keyword in front of the
name, and it should stay there. This RFC renames all to each: where each R: Display says
what is quantified (each field's type), and for a pack in RFC-0125 where each Ts: Copy says each
member. It deliberately does not write the bound as ..R: A. .. means "unpack" in every
other position the language gives it (a record tail, a row argument, a spread, a rest pattern, a
pack splice); in bound position it would be a quantifier, a second job for the same token.
1. The change
One kind, record
A generic parameter is a type, or record-kinded. The row kind is removed. A record R
parameter stands for a record type: an anonymous record or a record declaration (not a
struct or enum, whose rows are not visible to bounds and impls, per the same section).
Everything the spec already says about record T applies unchanged, including row bounds
(record R: { x: f64, .. }) and negative bounds (record R: !{ token }).
..R is an operator on a record parameter
..R is the row of R: its labelled fields, without any brand. It is valid only
where a row is wanted, and R must be record-kinded:
| position | meaning |
|---|---|
the tail of a record type: { x: f64, ..R } | the fields of R, spliced after x |
the argument of a nominal type: Packet<..R> | the anonymous record { ..R } |
a struct's residual tail: Handle.{ fd, ..R } | as today, the remaining fields |
..R anywhere else, or on a parameter that is not record-kinded, is an error. Anonymous
.. ({ x: f64, .. }) is unchanged.
R itself remains usable as a type. If R is an anonymous record, R and { ..R } are the
same type. If R is a branded record declaration, R keeps its brand and { ..R } is
the anonymous record with the same fields. In particular .. is the one operation that
forgets a brand while keeping the fields: the spec describes a declaration as a brand paired
with a row, and ..R is the "row" half of that pair.
The field-wise constraint
where each R: A holds when every field type in R's row satisfies A, vacuously on an empty
row. It is where all R: A with the keyword renamed and the same meaning, the same walkable
positions (an open-record tail, a where decomposition's remainder, the tail of an extend
target, a row argument of a nominal type) and the same forwarding rule (it is forwarded only to
another each R: A).
R: A keeps its present meaning, a bound on the record as a whole. The two are distinct forms of
one bound list:
fun show<record R>(r: { ..R }) -> String where each R: Display { … } // each field is Display
fun dup<record R: Copy>(r: R) -> (R, R) { (r, r) } // the record is Copy
each is a contextual keyword, recognised only at the start of a where bound as all is.
What does not change
- Row bounds (
record R: { x, .. }), negative bounds (!{ token }), and row equations (where R = { token: Token, ..Rest }). Equation sides are record types, soRestis now declaredrecord Rest. - Disjointness: a label written next to
..Rmust be absent fromR, as for rows today. - The field-wise constraint's meaning, positions and forwarding rule (RFC-0123); only the keyword changes.
- Rest patterns and record spreads at the value level (RFC-0178).
- Literal rows in row arguments (RFC-0180),
Req<..{ host: String, ..R }>. - Anonymous tails and where they are accepted (RFC-0181).
- Width subtyping and the by-value ownership rules for the extras.
- The
label Lkind proposed by RFC-0175: this RFC removesrow, not other kinds.
Diagnostics
row R becomes a targeted error ("the row kind was removed; declare record R"), and
where all R: A likewise ("all was renamed; write where each R: A"). ..R on a non-record
parameter says "..R takes the row of a record parameter; declare record R".
2. Alternatives considered
- Status quo. Two kinds, one reserved word, one contextual keyword. Nothing breaks. The cost is the vocabulary in Motivation §§1 and 3.
- Remove only
row, keepall. Fewer rewrites (no constraint rename). Declined:eachstates the distribution, and the rename is cheapest done in the same parser-based pass asrowtorecord; a later standalone rename would be a second migration over the same declarations. where ..R: Afor the constraint (the earlier draft of this RFC). Removes a keyword by reusing... Declined: it gives..a second job, a quantifier in bound position, where everywhere else it unpacks. A reader may also take..R: Displayto mean the row as a whole isDisplay.- A different keyword or token, e.g.
where R.*: A. A new token for what a word says plainly. - Keep
rowas a deprecated alias for one release. A transitional option, not a design alternative: see Open Question 3.
3. Costs
- A breaking rewrite of two shipped forms. 192 integration fixtures (410 sites) bind a
rowparameter and 45 (73 sites) usewhere all; 46 Markdown files in this repository mention one of them; the standard library (core.mtl) has 6 sites. Both rewrites are purely lexical and run in one parser-based pass, but the spec sections for RFC-0121, RFC-0123, RFC-0178, RFC-0180 and RFC-0181 are amended, and so is the spec's own map of the area. - Implementation.
rowis a kind in the parser, the AST, the registry (row kinds on type parameters), projection checking, and the type engine's row kinds. The change merges the kind and adds the operator check; it removes a parallel code path, but it touches every site that distinguishes the kinds. The constraint rename is a keyword change infield_wise_constraint. - Churn in pending RFCs. RFC-0125 (
all Ts: Copybecomeseach Ts: Copy), RFC-0146 (Self.Rwithrow R), RFC-0174 (fields(R)examples) and RFC-0175 (draft) userow Rorall.
Review findings (2026-10-27)
- Claims checked against the build and the corpus.
fun get_x<record R>(p: { x: i64, ..R })is rejected today withT0012, "Ris not a row parameter; declare it<row R>", as stated. 192 fixtures bind arowparameter (410 sites), 45 useall(73 sites), 46 Markdown files mention either form (25 of them RFC examples). The cost estimates in §3 stand. - The standard library is missing from the costs.
metel-frontend/stdlib/core.mtlhas 6row/allsites that the rewrite must cover. - Overlap with RFC-0188 and RFC-0187, resolved. RFC-0187 originally also proposed removing the
row kind (
<row R>to<R>,Packet<R>, no kind keyword). That part was dropped: this RFC is the single change to row declarations. RFC-0187 keeps only the{| … |}delimiters and RFC-0188 keeps only the label spelling. Both use this RFC'srecord Rand..Rin their examples. This avoids rewriting the same declarations twice (row R, thenrecord R, then bareR). - The constraint keyword (revised 2026-10-27). The first draft replaced
allwith..R: A. Reviewed and changed:..stays an unpacking operator, and the keyword is renamedalltoeachin this RFC. RFC-0125's pack bounds follow (each Ts: A).
Open Questions — resolved 2026-10-27
- Row arguments: keep
Packet<..R>or writePacket<R>? Resolved: keepPacket<..R>, defined asPacket<{ ..R }>. This RFC is the single rows change (review finding 3), so no argument position changes. Making arecord-kinded parameter take a record type (Packet<R>, dropping literal-row arguments) would change brand behaviour at the argument and is not part of this RFC. - Variadic packs (RFC-0125). Resolved: packs share the quantifier with rows, now spelled
each:each Ts: Copy. RFC-0125 is under review and unimplemented, so it changes spelling only. - Deprecation window or hard cut? Resolved: hard cut, as RFC-0136 and RFC-0098 were.
row Randwhere all R: Abecome targeted errors (see Diagnostics), and one parser-based rewrite migrates the corpus. - Can a generic body name
Ras a type whenRis an anonymous remainder? Resolved: yes, both spellings are allowed.Ris a type and{ ..R }is its anonymous record; they are the same type whenRis anonymous. They differ only whenRis a brandedrecord(Rkeeps the brand,{ ..R }drops it), which is a difference in meaning, not style, so no lint steers between them. record Rinstantiated by a brandedrecord. Confirmed. A branded record already goes through arow Rparameter today (get_x(p)withp: Pcompiles), andRis inferred as the anonymous remainder, so inference is unchanged...Rforgets the brand where one is supplied explicitly. A fixture for the explicit-instantiation case is part of the implementation.- Release timing. Resolved: v0.15.0, the milestone already on the tracking issue, so the release's explicit-syntax changes migrate once. The rewrites are sequenced as separate reviewable changes.
References
- RFC-0121 — Open Rows (introduced
row R; amended here). - RFC-0123 — Field-Wise Row Constraints (
where all R: A; amended here). - RFC-0178 — Row Remainder Construction (value-level
..rest patterns and spreads). - RFC-0180 — Literal rows in row arguments; RFC-0181 — Open rows in every type position.
- RFC-0137 — Nominal types as branded rows (the brand versus row distinction).
- RFC-0125 — Variadic Generics (
all Ts), RFC-0146 — Row-polymorphic self-views, RFC-0174 — Compile-time iteration over a row's fields, RFC-0175 — Label polymorphism (examples that userow R). reference/spec/types.md— "Records and Rows at a Glance", "Open rows", "Field-wise row constraints".
Decision
Outcome: (pending)