Field-Wise Row Constraints
Opened 2026-07-24, unifying three questions the corpus was carrying separately without noticing they were the same one. RFC-0121's open question 1 needs "every field in row
RisCopy" before its width-subtyping rule can be stated. RFC-0116 needs "every field in rowRisDisplay" before an anonymous record can be printed. And — found the same day while integrating RFC-0071 — no record can beCopyat all, which makes every record permanently affine. Three symptoms, one missing construct.Depends on RFC-0121 (Open Rows) — it quantifies over a row variable, so
<row R>,..R, and row-conditional impls must exist first. Deliberately not folded into RFC-0121: that RFC is already carrying row variables, row algebra, typestate, and the width-subtyping problem, and this cluster's repeated lesson is that large RFCs accumulate contradictions faster than they get read.
Status — implemented (2026-10-05). The specification's
where all R: Aspectconstraint is implemented for open-row parameters and row-conditional impls. In particular, an abstract by-value narrowing requireswhere all R: Copyand otherwise reportsT0033at the generic body's narrowing expression.
Summary
A constraint form that applies an aspect bound to every field of a row rather than to the row's type as a whole:
extend<row R> { ..R }: Display where all R: Display { … }
Read: this impl applies to any record all of whose fields are themselves Display.
Without it, three things the records cluster already promises cannot be written: an
implementation of any standard-library aspect for anonymous records, Copy for a record of
copyable fields, and the rule that makes width subtyping sound.
Motivation
1. Anonymous records cannot satisfy a stdlib aspect at all
RFC-0116 §3 permits an aspect implementation for a record only when the aspect is local
to the implementing module — the orphan rule, and correctly so: { x: f64, y: f64 } has
no owning module, so two modules writing extend { x: f64, y: f64 }: Display would conflict
with no principled tiebreak.
Every standard-library aspect is non-local. The consequence, which RFC-0116 states the restriction for but never draws:
let p := { x = 1.0, y = 2.0 };
println("${p}"); // no `Display` for any record, ever
A single stdlib impl would fix it for all records at once, and cannot be written today
because it needs to require Display of each field:
extend<row R> { ..R }: Display where all R: Display { … }
2. The width-subtyping rule cannot be stated
RFC-0121 §4 proposes that width subtyping — silently accepting a wider record where a
narrower one is expected — is sound only when every silently-dropped field is Copy.
Its own open question 1 records that this cannot be written: "No bound expressing 'every
field in row R is Copy' is defined anywhere."
3. No record can be Copy, which is sharper than either
Found 2026-07-24 while cross-checking RFC-0071 for integration. Copy is declared, not
auto-derived — RFC-0096's auto-impl set is a closed list of exactly three (Send, Sync,
Linear) and Copy is not among them (RFC-0071 §2 declares it with extend T: Copy;).
Since RFC-0116 §3 bans non-local aspect impls for records, and Copy is standard-library:
let a := { x = 1, y = 2 };
let b := a; // a MOVES. Every record is affine, forever.
{ x: i64, y: i64 } is exactly the shape a reader expects to copy freely, so this is a
harsher cliff than Display. It also blocks RFC-0121's width-subtyping rule from ever
applying to a nested record, since that rule requires each silently-dropped field to be
Copy and a record field can never be.
The fix is the same single stdlib impl shape:
extend<row R> { ..R }: Copy where all R: Copy { }
These are the same missing construct, and recognising that is most of this RFC's justification. Solving it once resolves a soundness rule and a usability cliff that were being tracked as unrelated problems in different documents.
1. Shape of the constraint, syntax ratified 2026-09-27
where all R: Aspect:
extend<row R> { ..R }: Display where all R: Display { … }
fun consume<row R>(r: { ..R }) where all R: Copy { … }
all R: A holds when every field type in R satisfies A. On an empty row it holds
vacuously.
It is a constraint, not a bound on the row itself. R: { x: f64, .. } constrains the
row's shape — which labels it has. all R: Display constrains the row's contents —
what the field types can do. The two compose and neither subsumes the other.
Why this spelling over the alternatives (OQ1, resolved). where all R: Aspect is the
cheapest extension of a channel that already carries two WhereConstraint alternatives —
the ordinary bound (ident : BoundList) and RFC-0121's row equation (ident = Type).
Adding all R : BoundList as a third costs exactly one keyword (all) and no new grammar
shape, the same cost RFC-0121 already paid for row. The alternatives considered and set
aside: R: all Display moves the keyword inside a bound-list entry for no compensating
clarity; a bound-position marker ({ ..R }: Display meaning "apply recursively") is
genuinely ambiguous with an ordinary row bound of the same shape; AllFields<R, Display>
invents a synthetic generic-looking construct that isn't an aspect, isn't a type, and
would need its own special-cased resolution rule. WhereConstraint gains:
WhereConstraint → RowEquation
| "all" IDENTIFIER ":" BoundList
| "record"? IDENTIFIER ":" BoundList
(Ordering matters only for parse-choice clarity, not semantics — all and record are
both keyword-led alternatives distinguishable from the lookahead token, unambiguous
against RowEquation's IDENTIFIER "=" and the bare IDENTIFIER ":" bound form.)
2. Why this is not simply "derive it"
Comptime derive (RFC-0093) could generate a per-shape implementation instead of quantifying.
That is a real alternative and is recorded in Alternatives — but it produces one impl per
concrete record shape encountered, whereas this produces one impl covering all of them.
The difference matters for a bound: fun f<row R>(r: { ..R }) where all R: Copy is a
constraint checked at the call site, and there is nothing to derive there.
3. Prior art
PureScript is the direct precedent. It expresses exactly this via RowToList, which
reifies a row as a type-level list, plus instance induction over that list — a Cons case
requiring the head's constraint and recursing on the tail, and a Nil base case. Its
Show, Eq and Encode instances for records are all written this way, so the shape is
known to work in a production row-typed language.
Haskell's row-types takes the same approach under the name Forall r c, with a
metamorph-style fold over the row's fields.
Both suggest the mechanism is induction over a reified row rather than a primitive — worth knowing before designing this as a built-in.
4. Mechanism: primitive, not reified (OQ2, resolved 2026-09-27)
Both known precedents (§3) implement all/Forall via row-to-list reification plus
ordinary instance induction — a real, working design, and worth weighing seriously before
picking a built-in instead of it. It is set aside here, not because it's wrong, but because
it solves a more general problem than this project currently has:
- Every actual use needed is "apply one fixed aspect to every field," never
user-programmable. RFC-0121's width-subtyping rule needs
all R: Copy; RFC-0116'sDisplaygap needsall R: Display. Neither needs a user to write their own inductive instance over an arbitrary row — reification's whole value is letting users do exactly that, and nothing on this project's critical path asks for it. - Reification is a real forward dependency this project doesn't want yet. A
type-level list needs type-as-value machinery to reify a row into, which is RFC-0092
(Comptime Core) — itself still
1-under-review. Buildingallon top of it would chain this RFC's acceptance to comptime's, the same critical-path risk this cluster has repeatedly split RFCs apart to avoid (RFC-0121's own header makes the identical argument against pulling in more than the cluster needs). - A primitive rule is a small, self-contained typechecker addition.
all R: Achecked as "for each field inR's (finite — see Open Question 4) structure, check the field's type againstA's bound, short-circuiting on the first failure" is a direct structural recursion, no new kind, no reification, and no dependency beyond RFC-0121's row machinery, already accepted.
Not a closed door. If a real need for user-authored row induction shows up later —
the same "don't build it until it materializes" bar RFC-0090/RFC-0121 already hold
<row R> itself to — reification is the well-precedented way to get there, and nothing
about shipping all as a primitive now forecloses it; a later reified form can coexist
with (or, for the compiler's own internal uses, ideally paper over) all's current
fixed-shape rule, similar to how RFC-0121 §3 lets row-conditional and phantom-parameter
typestate coexist rather than forcing one to subsume the other.
Open Questions
IsResolved 2026-09-27 — see "Why this spelling over the alternatives" in §1 above.where all R: Athe right surface? It reads well and reuseswhere, butallis a new keyword in a language that has been reluctant to spend them. Alternatives:R: all Display, a bound-position marker ({ ..R }: Display), or a named aspect (AllFields<R, Display>) — the last being closest to PureScript's constraint-class framing and requiring no keyword.where all R: Aspectratified: the cheapest extension ofWhereConstraint, which already carries the ordinary bound and RFC-0121's row equation; costs exactly one keyword, the same price RFC-0121 paid forrow.Is it primitive, or induction over a reified row? PureScript andResolved 2026-09-27 — see §4 above. Primitive: a direct structural recursion over the row's fields, no reification, no new kind, no dependency beyond RFC-0121's already- accepted row machinery. Every actual use on this project's critical path is a fixed single-aspect check, not user-programmable induction, so reification's extra generality isn't needed yet — and building on it now would create a forward dependency on RFC-0092 (Comptime Core), stillrow-typesboth derive it from a row-to-list reification plus ordinary instance resolution. A built-in is simpler to specify and harder to generalise; the reified form is more expressive and drags in type-level lists.1-under-review. Not a closed door: a reified form remains available later if a real need for user-authored row induction shows up.Does it need to be per-field rather than uniform?Confirmed 2026-09-27, not merely "probably." Any finite set of per-field exceptions chains via repeated decomposition —all R: Displayapplies one aspect to every field.Displayfor a record needs exactly that. But a heterogeneous requirement — "fieldxisCopyand the rest areDisplay" — is expressible as decomposition (R = { x: T, ..Rest } where T: Copy, all Rest: Display), so probably not. Unverified.where R = { x: T, ..R2 }, R2 = { y: U, ..Rest }, each exception constrained individually andall Rest: Acovering the remainder — soallitself never needs to be heterogeneous. §2's existing decomposition machinery (RFC-0121 §2) is exactly what makes this compositionally complete.What does it mean for a record containing a record?Resolved 2026-09-27 — not coinductive. An anonymous row type is written in finite source text and cannot be self-referential — Metel has no nominal recursion through anonymous records; only named types can recurse, and only through indirection (all R: Displayon{ inner: { a: i64 } }requires{ a: i64 }: Display, which requires the very impl being defined. Whether that terminates or needs a coinductive rule is unexamined, and PureScript's answer should be checked rather than guessed.&, allocator handles), which resolves through ordinary nominal impl search, never throughall's own unfolding.all R: Displayon{ inner: { a: i64 } }therefore terminates by plain structural induction on syntactic nesting depth, which is always finite. PureScript's induction-over-reification needing care there is about that mechanism working over nominally-recursive ADTs, not evidence this construct needs coinduction — it's the wrong precedent to import the concern from.How does it interact with the orphan rule? §1's motivating impl is a stdlib impl for a structural type, which is precisely what RFC-0116 §3 bans for non-local aspects. It is presumably fine because stdlib ownsResolved 2026-09-27 — no amendment needed, the premise was a misreading. RFC-0116 §3's aspect-impl rule is keyed on aspect locality ("Aspect impls, when the aspect is local to you" / banned "for a non-local aspect") — the "record has no owner" language is scoped to the separate inherent-impl ban a few lines below it, not to aspect impls at all. A stdlib impl ofDisplay, but the rule as written keys on the record having no owner, not on the aspect — so the wording may need amending rather than merely being read charitably.Display/Copyover{ ..R }, written inside the module that ownsDisplay/Copy, is an ordinary "aspect local to you" case, no different from any other module implementing its own local aspect for a record. The actual blocker is metel-core#581 (structural impl targets — records, tuples,T[]alike — unimplemented generally), a pre-existing implementation gap unrelated to this RFC's design.
Alternatives Considered
- Comptime derive per shape (RFC-0093). Generates an impl for each concrete record type
used. Avoids the constraint entirely; does not help the bound case (§2), and depends on
comptime (RFC-0092, RFC-0093), both
1-under-review— stale as0-drafthere since 2026-08-23; corrected 2026-09-27. - Compiler-builtin structural printing. Make
printlnknow how to print a record without going throughDisplayat all. Much the cheapest fix for §1's motivation, and it does nothing for §2's soundness rule — the two problems would stay separate, which is the situation this RFC exists to end. - Accept the gap. Records are not
Display; convert to a named record or struct to print one. Coherent, and honestly viable for a first release, but it leaves RFC-0121's width-subtyping rule unstatable regardless.
References
- RFC-0121 (Open Rows) — the prerequisite, and the source of open question 1's
Copyrule - RFC-0116 (Anonymous Record Types) §3 — the orphan-rule restriction whose consequence motivates §1
- RFC-0093 (Derive Registration) — the comptime alternative
- RFC-0092 (Comptime Core) — the type-as-value/reification machinery §4's primitive-vs- reified resolution deliberately avoids depending on for now
- RFC-0061 (Structural Aspect Bounds), RFC-0060 (Aspect Impl Coherence) — the coherence machinery a stdlib impl over all rows would have to satisfy
reports/substructural-types/structural-records.md— the living report the cluster was extracted from
Decision
Outcome: Ready for acceptance, 2026-09-27. No blocking open question remains: OQ1
(surface syntax, §1) and OQ2 (primitive vs. reified mechanism, §4) are ratified; OQ3
(heterogeneous fields), OQ4 (nested-record termination) and OQ5 (orphan-rule wording) are
resolved by inspection and need no design change. The sole prerequisite, RFC-0121, is
itself 2-accepted. The only work left is the spec-rule pass (coverage frontmatter +
Legality Rule blocks for the all R: Aspect constraint form and its field-wise checking
rule) done at the 3-integrated transition, as for RFC-0117 and RFC-0129.
Target: v0.14.0, via metel-core#793.