Row Remainder Construction
Summary
Open rows (RFC-0121) let a signature name the rest of a record, and a where equation
(R = { token: Token, ..Rest }) names what is left after removing a field. Nothing in the
language lets a generic body produce or consume a value of that remainder. This RFC
adds the two missing value-level forms, each the mirror of a form that already exists at
the type level:
- a rest pattern that binds the unnamed remainder of a record —
{ token, ..rest }— which moves the named fields out and gives the rest an owner; - a row spread in a record literal that places a record's fields into a new one —
{ ..rest, auth = a }.
With both, the typestate step that RFC-0121 §3 uses as its motivating example can be written:
extend<row R, row Rest> Session<..R> where R = { token: Token, ..Rest } {
fun authenticate(self) -> Session<..Rest> {
let { token: _, ..rest } := self.data; // rest : { ..Rest }
Session { id = self.id, data = rest }
}
}
Today that body cannot be written; the only fixture for the signature guards it with
panic.
Motivation
RFC-0121 §2 chose "extension is a literal, removal is a decomposition": the new label goes
in the row literal, and removal is stated by an equation that names both halves. That
settled the types. It left the body as { ... }.
Every attempt to fill the body fails at present (metel-core#1399):
- Building the result from the whole,
Session { id = self.id, data = self.data }, is rejected. Declared parameters are rigid (RFC-0173), so{ token: Token, ..Rest }is not{ ..Rest }, and no coercion removes a field. - Writing a removed label back (
value.data.token := ...) is rejected by design: a remainder cannot regain a label by assignment inside the generic definition. - There is no value-level spelling of "this record without
token". A record pattern's bare..discards the rest rather than binding it, and a record literal has no..-form at all ({ ..p, id = 1 }is a parse error), even though the same tail is written freely in a record type ({ ..R, id: i64 }).
The consequence is that the headline use of open rows — typestate over a row, where a step
consumes a field and returns the session at a smaller row — is expressible in its
signature and inexpressible in its implementation. The symmetric operation, extending a
record by a field inside a generic body ({ ..R, auth: String } as a value), has the
same gap. Concrete rows are unaffected: with known fields a literal can be rebuilt by
hand. The problem exists only for an abstract row, where the fields are not nameable.
Why it needs a design, not a patch
Ownership makes this more than a syntax gap. The remainder is not a view: when
token leaves a record, the other fields must still have exactly one owner, and the
fields that were taken must not be reachable again. RFC-0121's width-subtyping rule already
refuses to silently forget a non-Copy field and points to "an explicit
where R = { .., ..Rest } decomposition, which keeps Rest visible and owned" as the
alternative. This RFC supplies the operation that makes that sentence true: a way to take
Rest as an owned value.
Goals
- A generic body can take an abstract row apart and put one together, with the result typed by the row facts the signature already states.
- The operation is ownership-sound: every field of the source is either moved to a named
binding, moved into the remainder, or (if
Copy) copied; none is forgotten. - The new syntax mirrors syntax that exists, so it is learnable from the type-level forms.
- Rows keep their invariant — a label occurs at most once — and the checker can prove it without runtime tests.
Non-goals
- Label polymorphism (selecting a field by a label variable). That is RFC-0175; this RFC is the fixed-label case it builds on.
- Iterating a row's fields (RFC-0174).
- Narrowing inside a field (RFC-0150).
- Changing what
{ x: T, ..R }as a type means, or the by-value width-subtyping rule.
Proposal
1. Rest pattern: ..name in a record pattern
A record pattern may end in ..name, binding every field the pattern did not name, as a
record:
let { token, ..rest } := value; // value : { token: Token, extra: i64, flag: boolean }
// token : Token, rest : { extra: i64, flag: boolean }
This is the record counterpart of the array pattern's ..name, and a refinement of the
record pattern's existing bare .. (which remains discard-only; it does not produce an
implicit remainder value). It follows the same
irrefutability rule as the existing record pattern: it matches any record that has the
named fields.
Typing. The binder's type is the source row minus the named fields.
-
For a concrete row this is the concrete record of the remaining fields.
-
For an abstract row, use the strongest decomposition entailed by the facts in scope, remove the labels named by the pattern, and retain every other known field and the remaining open tail. For example, if the scrutinee has type
{ ..S }and the declaration statesS = { token: Token, extra: i64, ..Rest }, then{ token, ..rest }givesrest: { extra: i64, ..Rest }. If the decomposition isS = { token: Token, ..Rest }, it gives{ ..Rest }. -
If the named field is not entailed to be present, reject the pattern with a presence diagnostic ("
tokenis not known to be present inR; add a bound or an equation"). If presence is entailed but the facts do not determine the remainder type — for example, a boundR: { token, .. }with no decomposition — reject it with a distinct diagnostic explaining that the field is known to exist but a decomposition/equation is needed to determine the remainder type. Both diagnostics may useT0012, but their messages must distinguish the missing fact. -
If removing the named fields leaves no fields and no open tail, the bound value has empty anonymous-record type
{}(notUnit).The named fields' types are taken from the facts as well, so a bound with a field of unknown type binds it at the bound's type.
Ownership. Matching moves the scrutinee. Each named field is bound by the pattern and
follows the ordinary ownership rules for pattern bindings; ..name takes every other
field as one owned record. The scrutinee is consumed as a whole, so no residual of it
remains.
Owned scrutinees only. A rest pattern against a reference (let { x, ..rest } := &r)
is reserved: it is rejected with a message pointing at the owned form. Borrowing the
remainder (x: &T, rest: &{ ..Rest }, and a split &var) needs the shared/exclusive
rules of borrow checking (RFC-0122), and nothing in the motivating typestate case needs it,
since those steps take self by value. Recorded as GAP-EXPRESSIONS-001.
Nominal types. The scrutinee may be an anonymous record or a nominal record (whose
fields are all public). The remainder is always an anonymous record: the brand is not
carried over, because it would no longer describe the value. A struct is rejected —
its row is private to its module and the pattern would leak it — and a type implementing
Drop is rejected, since taking its fields apart would bypass its destructor.
2. Row spread: ..expr in a record literal
A record literal may contain ..expr among its field initializers, placing every field of
expr into the new record:
{ ..rest, auth = a } // rest : { ..R } ⇒ { ..R, auth: String }
This is the value-level form of the type-level tail { ..R, auth: String }; the position of
..expr among the initializers is not significant, matching record field order everywhere
else.
Typing. The literal's row is the union of the spread's row and the written labels. It is well-formed only if no label occurs twice, so each written label must be proven absent from the spread's row:
- if the spread's row is concrete, by inspection;
- if it is abstract, by a fact the checker already holds for the body: a negative bound
(
row R: !{ auth }), or the label a decomposition removed — a remainderRestfromR = { token: Token, ..Rest }lackstoken, sinceRcontainstokenonce. The checker installs both when it checks a generic definition (RFC-0173's implementation), so a spread's absence proof is a lookup, not new inference.
A literal whose absence cannot be shown is rejected (T0012, naming the label). A written
label that is present in the spread is rejected too: there is no override — a
spread adds fields, and replacing one is a field assignment (r.x := v) on an owned
value, as today. A literal has at most one spread: combining two abstract rows needs a fact that the two
are disjoint as wholes, which per-label bounds cannot state (GAP-TYPES-008). A leading
..expr cannot collide with a range, since the grammar has no prefix range, and { ..rest }
alone cannot be a block, so it is unambiguously a record literal.
Ownership. Spreading an owned record moves its fields into the result; the source is
consumed, as in §1. Spreading through a reference is allowed only if every field in the
spread's row is Copy (provably, for an abstract row: where all R: Copy) — the same
test and the same reason as RFC-0121's by-value width-subtyping rule — and otherwise is
rejected with a T0033-style message pointing at an explicit clone.
Evaluation order and layout. Initializers, including ..expr, are evaluated left to
right as written. The resulting record's field order/layout is determined by its row/type,
not by initializer order; record field order is not observable and does not affect type
identity.
3. Worked examples
The typestate step (signature unchanged from RFC-0121):
extend<row R, row Rest> Session<..R> where R = { token: Token, ..Rest } {
fun authenticate(self) -> Session<..Rest> {
let { token: _, ..rest } := self.data; // §1 case 2: rest : { ..Rest }
Session { id = self.id, data = rest }
}
}
The reverse step, adding a field (the equation here is for the result):
extend<row R: !{ token }> Session<..R> {
fun with_token(self, t: Token) -> Session<{ ..R, token: Token }> {
Session { id = self.id, data = { ..self.data, token = t } } // §2: token ∉ R
}
}
A generic utility, taking one known field out and handing back the rest:
fun take_id<row R>(r: { id: i64, ..R }) -> (i64, { ..R }) {
let { id, ..rest } := r;
(id, rest) // §1 case 1
}
Must not compile:
fun bad<row R>(r: { ..R }) -> i64 {
let { id, ..rest } := r; // error: `id` is not known to be present in `R`
id
}
fun bad2<row R>(r: { ..R }, a: String) -> { ..R, auth: String } {
{ ..r, auth = a } // error: `auth` is not known to be absent from `R`
}
fun bad3<row R>(r: &{ ..R }) -> { ..R } {
{ ..r } // error: cannot copy the fields of `R` out of a reference
}
4. Interactions
- RFC-0173 (rigid parameters). Neither form relaxes rigidity:
Ris never unified withRest. The remainder type comes from a stated fact (a bound's tail or an equation) and presence/absence are entailments checked against the declared bounds, so a body that builds{ ..Rest }is accepted exactly when its signature promises it. The checker already carries decompositions as symbolic facts through a body; this RFC uses them. - RFC-0121 width subtyping. Unchanged. The spread through a reference reuses its
Copyrule rather than introducing a second one. - RFC-0137 / RFC-0117 (narrowing). A pattern that moves fields out of a nominal value is a whole-value move here, so it does not produce a residual; partial moves keep their existing field-by-field narrowing. Moving every field out of a value (the degenerate empty remainder) is the case metel-core#1398 tracks and must behave consistently with it.
- RFC-0175 (label polymorphism).
drain_field<label L, row R, T>is the §1 pattern with a label variable in place oftoken. This RFC fixes the semantics for a known label so that RFC-0175 only has to add the variable. - Array rest patterns.
[a, ..rest]already binds the remainder of an array; the record form is deliberately the same shape. - Bare record
... It remains a discard, not an unnamed remainder value. A remainder that must remain available is bound explicitly with..name.
Alternatives considered
- A removal operator or method (
r.without(token),R \ token). Rejected for the reasons RFC-0121 §2 gave against row subtraction, and because a method needs a label as an argument (RFC-0175's label kind) to be generic, whereas a pattern names the label as syntax and needs nothing new in the type system. - Remainder projection,
self.data.{ ..Rest }. Reuses the existing.{ }projection, and avoids a new pattern form. But projection views a value without moving it (the residual is of the same brand, and the original keeps its other fields), so it answers a different question: it would leavetokenin the source, with the ownership puzzle this RFC exists to solve. It also gives no way to bind the removed field, which a typestate step usually needs. - Update-only, as Elm settled on. Assignment to existing fields works today; what is missing is changing the set of fields. Not enough for typestate.
- Copy-only remainder. Allow the forms only when the remainder is
Copy. This is the by-value width-subtyping rule and rules out the interesting case: a session or a handle whose remainder owns a buffer. - Spread with override (JS/Elm-style, last wins). Rejected: it breaks the row invariant that a label occurs once, and would make the literal's type depend on whether a label was present — undecidable for an abstract row without a presence fact.
Decisions and deferrals
Decided in this RFC:
- Spread spelling and count. One leading
..expr, allowed at any position among the initializers, mirrors the type-level tail. A literal has at most one spread, including for concrete rows (§2). - Record order. Initializers evaluate left-to-right, but the resulting field layout is determined by the resulting row/type; initializer order does not affect type identity.
- Absence facts. Presence and absence are entailments of facts the checker already records for a generic body (§2); this RFC adds no inference.
- No re-branding. A rest pattern or spread never produces a branded value. The
constructor call is written explicitly (
Session { ... }), which keeps construction invariants in one place (RFC-0114). - Records only. The forms are specified for anonymous records and nominal
records.
Deferred, each with a record in the Architecture Atlas:
- Rest patterns through a reference (borrowed remainder):
GAP-EXPRESSIONS-001, with RFC-0122. - More than one spread per literal:
GAP-TYPES-008. - Tuples, if RFC-0151 makes them numeric-label rows:
GAP-TYPES-009. The rules here are written so they extend to that case; whether..restover a tuple re-indexes is that RFC's decision.
The gap this RFC closes is GAP-TYPES-006.
Specification impact
Spec text lands only at 3-integrated. Sections affected: record pattern grammar and
exhaustiveness (expressions.md), record literal grammar and typing (types.md
Anonymous Records, Open rows), and the partial-move rules (ownership.md). New
diagnostics reuse T0012 (presence/absence not entailed) and T0033 (copying through a
reference); no new code is proposed. The "known limit" entry for a derived remainder in
the Records and Rows reference is removed on implementation.
Implementation notes
- Grammar:
field_pat_listgains..identbeside bare..; record literals gain a spread initializer. Both mirrorrow_tailand the arrayrest_pat. - Checker: presence/absence entailments reuse the row-fact machinery from RFC-0173's implementation; the remainder type is read from the same facts the equation already records.
- Runtime: a rest pattern builds a new record from the unnamed fields; a spread builds one from the spread's fields plus the written ones. Layout is known statically after monomorphization, so the compiled profile needs no dynamic row.
Decision
Outcome: Accept the fixed-label owned forms: ..name in a record pattern binds the
owned remainder as an anonymous record, and one ..expr in a record literal spreads an
owned row (or copies it through a reference only when every field is Copy). Remainder
typing removes the named labels from the strongest entailed decomposition and retains its
other known fields and open tail; the empty result is {}. Missing presence and an
undetermined remainder are distinct diagnostic cases. Bare .. remains discard-only, and
initializer order does not determine record layout. Borrowed rest patterns, multiple
spreads, and tuple forms remain explicitly deferred.