Row Narrowing
Extracted from RFC-0090 on 2026-07-24 (superseded; see RFC-0116's header for why the split happened and what the six pieces are).
Depends on RFC-0116 (Anonymous Record Types) and on RFC-0071 (Ownership and Move Semantics). RFC-0071 was
2-acceptedbut 0% implemented at the time this note was written — confirmed by direct grep of the interpreter for borrow/move-tracking infrastructure. That was a sequencing dependency on already-accepted work, not a ratification blocker on a draft, but it was the reason this is a separate RFC from RFC-0116 rather than bundled with it: the type-former was buildable and this was not. Corrected 2026-08-27: RFC-0071 is now3-integrated, and partial-move tracking is real, tested code (--move-check, gated off by default) — verified directly, not "0% implemented" any longer.
Status — under review (2026-08-23). Committed to v0.13.0, tracking issue #789 filed 2026-08-22 -- real dependency-chain engagement, not a calendar promotion
Updated 2026-08-29 (pre-acceptance review). Open Question 3 resolved: this RFC covers flat narrowing only — moving a whole field out, at any depth the field is reached, removes that one label. A record-typed field is moved as a unit. Narrowing a field of a record-typed field in place (
o.inner.a, leavingo : Outer.{ inner: Inner.{ b }, tag }) needs a recursive residual type system — new type grammar for a branded row whose field type differs from the declaration, tuple residuals, and recursiveDropreceiver shapes — none of which exist yet. That is RFC-0150 (Nested Row Narrowing), targeted with RFC-0147/0148. RFC-0137 references corrected to3-integrated. All three open questions now resolved.
Status — accepted (2026-08-29). Flat row narrowing: moving a field out narrows the value's type to the 2^N subset lattice, no row variables; path-sensitive via RFC-0071's existing move tracking. All three open questions resolved (OQ1/OQ2 via RFC-0137 §5, OQ3 by scoping to flat -- nested narrowing is RFC-0150). Pre-acceptance Codex review clean for the flat case.
Status — integrated (2026-08-29). Flat row narrowing integrated into reference/spec/ownership.md#narrowing (legality-1/legality-2, co-origin with RFC-0137), blocked-exempt on metel-core#858 pending move-triggered narrowing. Nested narrowing is RFC-0150.
Status — implemented (2026-09-03), metel-core#789. Anonymous-record row narrowing lands in v0.13.0, in both the inference and construction passes, reusing the
crate::flow_state::FlowStatemachinery RFC-0137 slice 2 (#858) threaded through both for the struct case. A partial move of a non-Copyrecord field narrows the binding to the record type with that label gone; a whole-value use afterward is a plainT0001at type-check time (the ordinary record-shape mismatch — an anonymous record has no distinct residual-type marker the way a struct's brand gives one). ACopyfield read by value does not narrow; an unresolved field type is held in the row until known (the field'sCopy-ness is re-tested at each read, since a record literal's field types are inference variables until solved).spec.ownership.narrowing.legality-1/legality-4,spec.ownership.partial-moves.which-constructs-support-partial-moves.legality-2(rewritten — records now narrow). Nested narrowing is still RFC-0150; the residual-type ⇄--move-checkgap (a whole-value use of a narrowed binding) was shared with #858 and is closed by metel-core#950 —--move-checknow accepts such a use.
Status — implemented (2026-09-03).
Summary
Moving a field out of a record narrows the record's type to exactly the fields that
remain. { fd: i64, path: String } with path moved out becomes { fd: i64 } — not a
partially-valid value, not an opaque "moved-from" marker, but an ordinary value of a
narrower record type.
No row variables and no unification are involved. For a closed record over N fields the space of possible residuals is the subset lattice, bounded by 2^N and trivial at realistic struct sizes.
The same rule applies to a nominal struct's own row, not only an anonymous record's
(RFC-0137, 3-integrated) — see §1's own worked example.
Motivation
Without narrowing, a record is a product type you can build and read but never partially
consume, and the natural pattern of "take one field out, keep using the rest" has no
expression. Rust's answer is to track partial moves as compiler-internal state that the
type does not reflect; the value's type stays Foo while the compiler separately
remembers which fields are gone.
Making the residual a real type rather than hidden state is what lets it be passed to
a function, returned, and named in a signature — which is the whole point of the
downstream features (RFC-0119's to_record/from_record round trip, and eventually
per-field multiplicity, which is deferred until records are implemented).
1. The rule
let r := { fd = 3, path = "/tmp/x" }; // { fd: i64, path: String }
let p := r.path; // r : { fd: i64 } -- moving a non-Copy field
// out is implicit, no separate `move` syntax
Narrowing is a type-level consequence of an ordinary partial move, not a separate operation with its own syntax. Nothing is written at the narrowing site beyond the move that causes it.
The residual is an ordinary value. It can be bound, passed, returned, dropped, and narrowed again. It is not a special "partially moved" state that must be repaired before use.
Narrowing is path-sensitive, following RFC-0071's move tracking. The residual type at
a program point is determined by the set of fields moved on every path reaching it: a
field moved on one arm of an if (and not the other) is conservatively treated as moved
after the join, and a move carried around a loop participates in the same fixpoint the
move checker already computes. Narrowing adds no new control-flow analysis — it is the
type-level reading of the move state RFC-0071 already tracks per binding.
The same rule applies uniformly to a nominal type's own row, not only to an anonymous record's (dependency discharged, see §3 below).
struct Handle { fd: i64, name: String }
fun main() {
let h := Handle { fd = 3, name = "x" };
let n := h.name; // h : Handle.{ fd } from this point on, same brand as Handle
}
Handle.{ fd } is a real, ordinary value of the same brand as Handle — not a
coincidentally-shaped anonymous record. RFC-0137 (Nominal Types as Branded Rows,
3-integrated) supplies the (brand, row) representation this depends on; the rule
itself, the subset-lattice bound, and "the residual is an ordinary value" all apply
exactly as stated above, for either kind.
A record-typed field is moved as a unit. let i = o.inner removes the whole inner
label, giving o : Outer.{ tag } — the residual's row never carries a narrower type
for a field it still holds. Narrowing a field of inner in place is nested narrowing,
deferred to RFC-0150 (§3).
2. Why this needs no row machinery
A closed record over N fields has at most 2^N residual shapes, all of them concrete record types that RFC-0116 can already express. Narrowing computes one concrete type from another concrete type by removing a label. A field is either present at its declared type or gone — a record-typed field is one label, not a sub-lattice — so there is no unification variable, no row kind, and no inference problem. (Recursing into a record-typed field, which would make the residual space a product of sub-lattices and needs residual field types the type grammar does not yet express, is RFC-0150.)
This is the load-bearing reason narrowing is specified here rather than in RFC-0121: it looks like row polymorphism and is not. Abstracting over which residual a function accepts is a genuinely different capability and belongs to RFC-0121.
3. What this RFC does not cover
- Widening. Assigning a moved-out field back is the inverse operation, and it raises a
question narrowing does not: whether the reassembled value satisfies whatever invariant
its type was built with. That is RFC-0114's (
Construct), which specifies that row completion fires a constructor rather than being a bare write. Narrowing a nominal type. WhetherDependency discharged, 2026-08-25 — RFC-0137 (Nominal Types as Branded Rows) answers this directly: everyHandlenarrows toHandle.{ fd }on partial move — as opposed to a record narrowing to a record — depends on nominal types carrying rows at all, which is RFC-0120's question and, in its strong form, an open exploration (reports/substructural-types/nominal-types-as-branded-rows.md).structcarries(brand, row)unconditionally, and narrows toHandle.{ fd }on partial move by exactly this RFC's own mechanism, at the same brand. This RFC's own scope should now be understood as extending to nominal narrowing via RFC-0137 as the supplying dependency, not excluding it — full nominal-type worked examples in this RFC's own "Proposed Design" are a real follow-up this correction does not itself perform, left for this RFC's own review to take up. Caveat, 2026-08-25 same day: RFC-0137 was reverted to1-under-reviewthe same day it was accepted (its own Open Questions 5-6, opened on reversion). The design reasoning above is unchanged. RFC-0137 was re-accepted 2026-08-27, all four Open Questions closed. Follow-up performed, 2026-08-27: §1 above now carries the nominal-type worked example this note called for. This item no longer belongs under "what this RFC does not cover" — kept here, not moved, per this corpus's append-only convention, but nominal narrowing is now within this RFC's own stated scope, not excluded from it.- Nested narrowing. Narrowing a field of a record-typed field in place
(
o.inner.a, soo : Outer.{ inner: Inner.{ b }, tag }) — as opposed to moving the wholeinnerfield as a unit — is RFC-0150 (Nested Row Narrowing). It needs a recursive residual type system this RFC deliberately avoids: a type grammar for a branded row whose field type differs from the declaration, residual types for tuple fields (RFC-0071 §9a tracks tuple elements like struct fields), recursiveDropreceiver shapes rather than a flat required-field set, and control-flow-join rules for path-dependent nested residuals. RFC-0150 is targeted alongside RFC-0147/0148, whose narroweddropreceiver forms it depends on. - Borrowed narrowing. Narrowing a
&varview rather than an owned value is RFC-0119's by-reference mode and RFC-0109's views. - Per-field multiplicity. Deliberately out of scope for the whole records cluster until records are implemented — see this RFC's References.
Open Questions
What is the interaction withResolved, 2026-08-25 — RFC-0137 §5. Dispatch is row-bounded: the compiler computes, once perDrop? If a record type could carry custom teardown this would be the hard case — a narrowed residual reaching end of scope with the destructor's required fields already gone. RFC-0116 §3 forbids customDropon records outright, so for records this question does not arise. It arises the moment narrowing is extended to nominal types (RFC-0120), and a concrete leak example is worked through inreports/substructural-types/nominal-types-as-branded-rows.md§4. Recorded here so the extension does not inherit the exemption silently.Dropimpl, a fixed concrete set of fields the destructor reads (including transitively through helper-method calls, per RFC-0137 §5's 2026-08-25 update), and the destructor fires against any residual whose row is a superset of that set. No leak — the destructor is never skipped for lacking a field it never reads. Updated 2026-08-28. RFC-0137 §5 was amended: the required set is no longer computed from the body but declared on thedropreceiver type (RFC-0109 named view, or RFC-0146/RFC-0147'sSelf.R). The row-bounded dispatch rule (residual ⊇ required set) and the no-leak property are unchanged; only where the set comes from changed.Does narrowing interact correctly with RFC-0071 §7's blanket ban on partial moves out ofResolved in design, 2026-08-25 — not yet in the implementation. RFC-0071 §7's blanket ban is superseded in design, not narrowed by an exception, by RFC-0137 §5's row-bounded dispatch — the exact refinement this question anticipated (narrow the ban to the fields a destructor actually reads), specified concretely rather than left to RFC-0091's now-deferredDrop-implementing types? RFC-0071 bans them wholesale; a narrowing-aware design might narrow the ban to the fields a destructor actually reads. That refinement was drafted in RFC-0091 §1 (uses (fd)), which is now deferred. Whether the ban simply applies as written, or needs revisiting for records, is unresolved.uses (fd)mechanism. Corrected the same day: RFC-0071 §7's ban is not an unimplemented gap RFC-0137 fills — it is real, tested,--move-check-enforced behavior today (off by default; a separate default-on migration is tracked). Until RFC-0137's own row-bounded mechanism is actually built,--move-checkcontinues to reject every partial move of aDroptype unconditionally, exactly as RFC-0071 §7 states.Is the 2^N claim actually the right bound in the presence of nesting? A record whose field is itself a record has residuals in both dimensions. Believed fine — narrowing is per-value, not recursive — but not checked.Resolved 2026-08-29 — this RFC is flat; nesting does not arise. A record-typed field is moved as a unit (let i = o.inner→o : Outer.{ tag }); a residual's row never carries a narrower type for a field it still holds. So the bound is exactly2^Nover the value's own fields, andRnever recurses. The moved-outinneris then its own value with its own independent2^Mlattice — no interaction, no product. Recursive (nested) narrowing —o.inner.anarrowingo.inner's type in place — is a real capability but a separate one: it needs residual field types the type grammar does not express, a residual form for tuple-typed fields, recursiveDropreceiver shapes, and join rules for path-dependent nested residuals. It is RFC-0150 (Nested Row Narrowing), §3, targeted with RFC-0147/0148.
References
public/rfcs/5-superseded/rfc-0090-structural-records.md§3 step 1 — the source, which bundled narrowing with the type-former- RFC-0116 (Anonymous Record Types) — the type-former this narrows
- RFC-0071 (Ownership and Move Semantics,
3-integrated) — supplies the move tracking this rule is a type-level consequence of; partial-move tracking itself is gated behind--move-check, off by default - RFC-0114 (Constructor Aspect and Canonical Construction) — the inverse operation:
completing a row fires
constructrather than a bare write reports/substructural-types/nominal-types-as-branded-rows.md§4 — theDrop-dispatch leak that arises when narrowing is extended to nominal types- RFC-0137 (Nominal Types as Branded Rows,
3-integrated) — supplies nominal-type narrowing directly and the branded(brand, row)representation; §3's own stated dependency folded in 2026-08-27 (§1's nominal-type worked example); its §5 row-boundedDropdispatch resolves Open Questions 1 and 2 - RFC-0150 (Nested Row Narrowing) — the recursive extension: narrowing a field of a
record-typed field in place. Depends on this RFC and on RFC-0147/0148's narrowed
dropreceiver forms; carries the residual-type-grammar, tuple-residual, and control-flow-join questions this RFC leaves out (Open Question 3, §3) public/rfcs/1-under-review/rfc-0089-linear-types.md,public/rfcs/1-under-review/rfc-0091-linear-records.md— per-field multiplicity, deliberately deferred until records are implemented
Decision
Outcome: Accepted and integrated 2026-08-29. Flat row narrowing: moving a field
out narrows the value's type to the closed 2^N subset lattice, at the same brand for a
struct; path-sensitive via RFC-0071's existing move tracking. All three open questions
resolved — OQ1/OQ2 via RFC-0137 §5's row-bounded Drop dispatch, OQ3 by scoping this RFC
to flat narrowing (nested/recursive narrowing is RFC-0150). Integrated into
reference/spec/ownership.md#narrowing (spec.ownership.narrowing.legality-1/legality-2,
co-origin with RFC-0137), blocked-exempt on metel-core#858 pending move-triggered
narrowing.
Target: v0.13.0, via metel-core#789.