Blog · language design
A Struct Is Already a Row
The first post said records would get their own writeup once the design was further along. It is — and the design changed on the way. So this is both the promised post and a correction to it.
The bridge that isn't there
In July the plan was a bridge: a struct would opt into a structural view with a derive
(#derive(ToRecord, FromRecord)), expose its fields as a row via to_record(), and take
them back with from_record(). The point was partial consumption: let the program say this field is gone, these are
still here, with Rc / Arc teardown as the case that needs it. After more reading and
feedback, I went a different route: a struct doesn't need converting to a row. It is one.
Every struct is a branded row
A struct declaration is two things: a row of fields, and a brand, a nominal identity
that says "this is a Handle, not any other row that happens to have the same fields."
The two come apart. You can project a sub-row and keep the brand:
struct Handle { fd: i32, name: String }
fun main() {
let h := Handle { fd = 3, name = "log" };
let just_fd: Handle.{ fd } := h.{ fd }; // brand kept, one field left
assert(just_fd.fd == 3);
}
Handle.{ fd } is a residual: the Handle brand narrowed to one field. It isn't an
anonymous record. A parameter written Self.{ fd } accepts it, and projecting it back to
full width gives plain Handle again.
A residual doesn't loosen privacy: from outside the declaring module you can't read, project,
or name a private field through one, and a public function signature naming one (even as
part of a residual type like Ticket.{ secret }) is rejected with T0009, since the name
would otherwise leak into the module's public API.
Partial moves narrow the type
What the bridge was for now just happens. Move a non-Copy field out of a struct and the
value's static type narrows to the residual:
struct Handle { fd: i32, name: String }
fun read_fd(fd: i32) -> i32 { fd }
fun main() {
var h := Handle { fd = 3, name = "log" };
let taken := h.name; // `name` moved out: h : Handle.{ fd }
assert(read_fd(h.fd) == 3); // fd is still there, so this is fine
h.name := "reopened"; // put it back: h : Handle again
assert(h.name == "reopened");
}
This is a type-checker fact, not a lint: after the move, h is a Handle.{ fd }, and
handing it to a function expecting a whole Handle is a T0001 type error, not a runtime
panic. The narrowing is path-sensitive: each if / match arm forks from the state before it,
and the arms rejoin afterward. Reading a Copy field narrows nothing; only a move takes a
field away. Assigning it back widens the type again, with no FromRecord, because field
assignment already bypasses constructor invariants. Move the last non-Copy field and
what is left is the empty residual Token.{}, which keeps the brand and widens on
reassignment.
The Rc case
The motivating case from the first post was Rc/Arc teardown: the payload dies when the
last strong reference does, while the counters stay live for weak references. Owning the box
yourself, that's exactly the narrowing above:
struct RcBox<T> { strong: i64, weak: i64, value: T }
fun log_counts(strong: i64, weak: i64) -> i64 { strong + weak }
fun into_value<T>(box: RcBox<T>) -> T {
let v := box.value; // box : RcBox<T>.{ strong, weak }
log_counts(box.strong, box.weak); // the counters are still readable
v
}
fun main() {
assert(into_value(RcBox { strong = 1, weak = 2, value = 7 }) == 7);
}
Doing the same from Rc's own drop needs one more piece — a method that borrows only part
of self (&var Self.{ strong, weak }) — specified in RFC-0137 §5 but not built
yet, so a type with Drop still can't be partially moved under --move-check.
"I only touch these fields", generically
The other half of the first post's motivation was the generic version: a function that
wants any value with an x and a y without naming a type. That's a row bound:
fun magnitude_sq(p: { x: f64, y: f64, .. }) -> f64 {
p.x * p.x + p.y * p.y
}
fun main() {
assert(magnitude_sq({ x = 3.0, y = 4.0 }) == 25.0); // exactly x, y
assert(magnitude_sq({ x = 3.0, y = 4.0, z = 9.0 }) == 25.0); // extra z is fine
}
.. makes the row open: at least these fields. Drop it and the bound is closed: exactly
these, nothing else. To name the argument's type, say to hand the same value back, declare
it: fun reset<record T: { x: f64, .. }>(p: T) -> T. A bound can also be negative:
!{ token } accepts any record with no token label.
A plain struct never satisfies a row bound. The brand is the reason a struct is nominal, and erasing it silently to fit a structural bound would defeat the point. Struct data flows through row-bounded code by handing over the fields explicitly; there is no implicit struct-to-row coercion.
Prior art: Niko's view types and Rust's 2026 goals
Niko Matsakis has been working on the same problem for Rust: a function that touches one
field shouldn't have to give up the whole value. His series on view types — View types for
Rust (2021), View types redux and abstract fields (2025), Maximally minimal view
types (March 2026), and its follow-up — answers it with a view type,
Manifest.{ statistics } in the latest spelling. Metel's residual Handle.{ fd } is the same
idea, down to the dot. It's also no longer just a blog series: view types (task
owner Sasha Pourcelot, compiler champion Oli Scherer) and the broader
field-projections effort (task lead Benno Lossin, Niko as the types-team
champion) are both accepted 2026 Rust Project Goals, with compiler experiments underway and
Ty.{ fields } itself one of the candidate syntaxes.
The two diverge in where they spend their effort. His views can be borrowed
(&mut *self.{ statistics }); Metel's are moved, since borrowing a view needs a borrow
checker (RFC-0122). And where he defers inferring field sets, Metel took the other road
early: row polymorphism, so a function can be generic over exactly the fields it doesn't
touch. The framing, the partial-move example and the dot spelling come from his series,
which predates Metel's first commit (March 30, 2026) — it's the best introduction to the idea
itself.
Records with no brand to begin with
Data that has a shape but no identity is its own type: { x = 1.0, y = 2.0 } as a value,
{ x: f64, y: f64 } as a type.
- Exact. Field order doesn't matter:
{ x: i64, y: i64 }and{ y: i64, x: i64 }are the same type.{ x: i64 }and{ x: i64, y: i64 }are not, and neither is a subtype of the other. There is no width subtyping. - No inherent behaviour. No methods and no
Drop. An anonymous record getsCopy,Display,Clone,SendandSyncfrom its fields, and an aspect can be implemented for a record shape. If you want methods attached, you want a brand. - They narrow the same way.
let a := r.left;leavesr : { right: i64 }. With no brand on what's left, a stale whole-value use is an ordinary shape mismatch (T0001) rather than a "partially-moved" diagnostic. { x }in statement position is still a block. Where it's ambiguous, parenthesize:({ x = e }).
record: the brand you can see through
That leaves a gap. A struct has a brand but its row is invisible to row bounds. An
anonymous record has a visible row but no brand. v0.14.0 fills it with a named record,
written record instead of struct:
record Point { x: f64, y: f64 }
fun magnitude_sq(p: { x: f64, y: f64, .. }) -> f64 {
p.x * p.x + p.y * p.y
}
fun main() {
assert(magnitude_sq(Point { x = 3.0, y = 4.0 }) == 25.0); // a record passes
}
A record X { … } is a struct X { … } in every respect (name, brand, construction,
field access, coherence) except one: its declared row is structurally visible. It
satisfies row bounds and matches row-conditional impls. The same code with struct fails:
struct Point { x: f64, y: f64 }
fun magnitude_sq(p: { x: f64, y: f64, .. }) -> f64 {
p.x * p.x + p.y * p.y
}
fun main() {
magnitude_sq(Point { x = 3.0, y = 4.0 });
// T0012: `Point` is a struct, but a struct never satisfies a row bound;
// declare it as a `record` instead
}
Switching from struct to record is additive: name, brand, generic parameters and impls
are unchanged, and the type newly passes row bounds. The cost is that every field becomes
public, because publishing the row is what lets other code match on it. A record field
can't be private; writing public on one is a syntax error that points you at struct.
A record also gets field-wise built-ins: Copy, Display and Clone derive from the
fields (Display prints in label order and keeps the record's name). A struct doesn't
acquire any of them just because its fields have them. Field-wise Eq, Hash and Ord
are deferred.
Open rows
An open row in a bound says at least these fields. v0.14.0 lets you name the rest:
..R is a row tail, row R declares a row parameter, and a function can promise
something about its tail without knowing what is in it. The case that shows what this buys
is a typestate: strip a field off a record and put it back, with the compiler tracking
what is left.
// `Rest` is whatever the argument has besides `token`; the call derives it.
fun strip<row Rest>(d: { token: String, ..Rest }) -> { ..Rest } {
let { token: _, ..rest } := d; // `..rest` binds everything not named
rest
}
// `!{ token }` guarantees the label being added isn't already there.
fun add_token<row R: !{ token }>(d: { ..R }) -> { token: String, ..R } {
{ token = "secret", ..d } // spread the rest of `d` after `token`
}
fun main() {
let redacted := strip({ token = "t", user = 3 }); // { user: i64 }
assert(redacted.user == 3);
let signed := add_token(redacted); // { token: String, user: i64 }
assert(signed.user == 3);
}
Three things are new there. A row tail beside other fields, { token: String, ..Rest },
says what the argument is made of. A remainder pattern, ..rest, binds everything the
other patterns didn't take, as an owned row. A spread, { token = "secret", ..d },
builds a record from one owned row, with spread labels unable to overwrite explicit
fields. When the row belongs to a named type and there is nowhere to write the tail inline,
a row equation (where R = { auth: String, ..Rest }) says the same thing from outside.
What happens to the fields the parameter doesn't name? A by-value open-row parameter takes
the whole record. The callee owns whatever R carries and can return it, destructure it
with a remainder pattern, or let it drop at the end of its scope, so the extra fields don't
need to be Copy:
fun get_x<row R>(p: { x: f64, ..R }) -> f64 { p.x }
fun main() {
let r := { x = 1.0, label = "gone".to_string() }; // `label` is not Copy: fine
assert(get_x(r) == 1.0);
}
The inline spelling and the equation form (where R = { token: String, ..Rest }) accept the
same programs, which they didn't before this release. Passing the whole parameter on to
another open-row function needs no bound either. Using a row more than once, though, needs
one more declaration: a generic function can't see its own row's fields, so a second use of
the same value requires where all R: Copy to constrain every field, open tail included.
Here is that second use. get_x(p) + get_x(p) hands the whole parameter on twice, which is
only sound if the value can be copied. where all R: Copy says so for every field, the
tail included:
fun get_x<row R>(p: { x: f64, ..R }) -> f64 { p.x }
fun twice<row R>(p: { x: f64, ..R }) -> f64 where all R: Copy {
get_x(p) + get_x(p)
}
fun main() {
assert(twice({ x = 1.5, y = 2 }) == 3.0); // `y` is Copy: fine
// twice({ x = 1.0, label = "gone".to_string() });
// T0012: field `label` of type `String` does not implement `Copy`
// (required of every field by `where all .. : Copy` on `twice`)
}
Leave the bound off and twice is rejected under --move-check as a use of the moved value
p (T0019), because nothing promises the caller's fields can be copied. The bound moves
that requirement to the call site, where the fields are known.
A builder that can't skip a step, or take them out of order
Open rows make a builder whose missing steps are compile errors, and the same row bounds can
make the order of certain steps a compile error too. The settings live in a record's row, and
each setter returns a Request whose row gained one field, written as a literal row,
..{ host: String, ..R }. retries has no dependency, so it can run whenever; authenticate
requires host to already be in the row; send requires both host and token:
record Request<row R> { data: { ..R } }
extend<row R: !{ host }> Request<..R> {
fun host(self, h: String) -> Request<..{ host: String, ..R }> {
Request { data = { host = h, ..self.data } }
}
}
extend<row R: !{ retries }> Request<..R> {
fun retries(self, n: i64) -> Request<..{ retries: i64, ..R }> {
Request { data = { retries = n, ..self.data } }
}
}
extend<row R: { host: String, .. }> Request<..R> where R: !{ token } {
fun authenticate(self, t: String) -> Request<..{ token: String, ..R }> {
Request { data = { token = t, ..self.data } }
}
}
extend<row R: { host: String, token: String, .. }> Request<..R> {
fun send(self) -> String { "GET " + self.data.host + " as " + self.data.token }
}
fun main() {
let sent := Request { data = {} }
.retries(3) // no dependency, any time
.host("a.com") // must come before authenticate
.authenticate("secret") // must come before send
.send();
assert(sent == "GET a.com as secret");
}
Skip a required step, or call a dependent one out of order, and the method just isn't
there — the same "no row-conditional implementation matches" error as the record/row-bound
mismatch earlier, and setting host or token twice is rejected the same way too. The order
constraint isn't a separate mechanism from the missing-step one; it's the same row bound,
just pointed at a field a previous step produces instead of a field the terminal operation
needs. A step with no such bound, like retries, stays free to happen whenever.
Matching
Struct patterns match named structs, and record patterns bind a remainder:
struct Token { kind: i64, span: i64, text: String }
fun handle(kind: i64, span: i64) -> i64 { kind + span }
fun main() {
let token := Token { kind = 1, span = 2, text = "t" };
let r := match (token) {
Token { kind, span, .. } => handle(kind, span), // `..` skips `text`
};
assert(r == 3);
let { id, ..rest } := { id = 7, name = "a", age = 3 }; // rest : { name, age }
assert(id == 7 && rest.age == 3);
}
One difference from the narrowing above is worth knowing before you hit it. Moving fields
out one at a time keeps the brand; a rest pattern does not. ..rest binds a new anonymous
record, and the nominal brand stays behind:
record Token { value: String, id: i64 }
fun take(t: Token.{ id }) -> i64 { t.id }
fun main() {
let a := Token { value = "x".to_string(), id = 1 };
let v := a.value; // a : Token.{ id }, still a Token
assert(take(a) == 1);
let b := Token { value = "y".to_string(), id = 2 };
let { value: _, ..rest } := b; // rest : { id: i64 }, no brand
assert(rest.id == 2);
}
Hand rest to something that wants Token.{ id } and the compiler says why, and tells you
to move the fields out one at a time to keep the brand. Methods and aspect impls on rest
are likewise those of an anonymous record, not Token's.
Patterns take bare field bindings only for now: a sub-pattern on a field, as in
Token { kind: 1, .. }, isn't in yet. The trailing .. is required unless the pattern
names every field, and naming a private field from outside its module is rejected, as it is
in construction.
Where it lands
Scoring the first post's "closest thing to an open problem" against what's built:
| status | |
|---|---|
| owned decomposition | done: partial move narrows to a residual, reassignment widens it back |
| explicit projection | done: v.{ a, b } is a first-class sub-row that keeps the brand |
| generic "only these fields" | done: row bounds, open row parameters, equations, field-wise bounds |
| a nominal type you can match structurally | done: record |
decomposition through a &var | specified, not built: the narrowed &var self receiver (RFC-0137 §5, planned) |
Drop dispatch on a residual | specified, not built: the same receiver, seen from the destructor |
The last two are one feature. What is still being settled is the receiver form itself, in
the self-view RFCs (0109 and 0147, both under review). Until it lands, the ban on partially
moving a type with a Drop impl stays in force under --move-check.
Still ahead: tuples as numeric-label rows (RFC-0151, a draft), field-wise Eq, Hash and
Ord, and the unified identity mechanism a brand is one case of (RFC-0159, in review). The
API I would want on top of this, and what it would let you build, is a separate post.
Everything else since the first post
Six releases have shipped since the first post went up on July 15, plus this one — named records, open rows, array types, and the move checker among them. Some of it was breaking, on purpose: Metel is still experimental, and I'd rather fix a spelling now than carry it. The changelog has the full list.