3 minute read

It’s pretty annoying that in Rust, #[non_exhaustive] structs don’t support “struct update” / “functional record update” syntax (the syntax that powers Stuff { a: b, c: d, ..blah }).

Just to make sure we’re all on the same page:

  • Functional record update syntax (a.k.a FRU syntax, or “struct update syntax”) is a handy Rust feature that allows you to make a new instance of a structure type where you produce a new struct that lets you make a copy1 of an existing struct that has updated values for a some of the fields.

  • #[non_exhaustive] is an attribute that can be added to certain types, to prevent downstream users from breaking when new fields are added to structs, new variants to enums, and so on.

Hopefully you’ve seen these features. If not, now’s a good excuse to go learn about them.

Anyway, my gripe is that if you have #[non_exhaustive] on a struct, then users outside your crate lose the ability to use FRU syntax with that struct. That is, if I have:

#[non_exhaustive]
pub struct Foo {
    pub a: u32,
    pub b: u32,
}

Then the users of my type (in another crate) can’t do:

let some_foo = Foo { a: 1, b: 2 };
// not allowed because `Foo` is non_exhaustive:
let updated = Foo { a: 3, ..some_foo };

This is a little silly because it doesn’t actually prevent the user in the external crate from performing that update operation. IOW there’s a workaround, which is this:

let some_foo = Foo { a: 1, b: 2 };
// this is how you can update `non_exhaustive` types:
let updated = {
    let mut tmp = some_foo;
    tmp.a = 3;
    tmp
};

Unfortunately, it’s verbose, imperative (as opposed to declarative like the FRU syntax), and annoying.

You might wonder why FRU syntax doesn’t allow this, and it’s because it’s desugaring has each field b: some_foo.b. And the reason for that.

What a headache.


Why do I care, there are a few situations where a hypothetical FRU+non_exhaustive that doesn’t have that limitation would be great. For example, C structs where new fields may be added to the end by upstream, or cases where you’re replacing possibly-incomplete builders. In such cases you want users to do something like:

let some_instance = thelib::TheType {
    their: options,
    go: here,
    ..TheType::default()
};
// or
let foo_sys = thelib_sys::foo_t {
    // C code knows which fields are available in which version
    version: 1,
    field1: something,
    etc: yougettheidea,
    // and then even if all the fields are present in practice,
    // you might get more in the future, so force the caller to
    // do:
    ..Default::default()
};

In these examples you’re leveraging non_exhaustive and FRU together, and it could lead is a pretty nice combination.

So, while long term it would be great if we could get some sort of syntax for doing this kind of FRU (perhaps adding a move in there, like ..move Default::default()?), for now we’re shit out of luck, and there’s no way to force the FRU (thus avoiding MSRV breaks when a new field is added)? Well, not totally.

You can work around this by making it really hard to depend on the exact set of fields, kind of the way we used to do it before non_exhaustive stabilized. I won’t go into too much detail (out of laziness, TBH), but you do something similar to a sealed type. You can’t fully seal it, but you can make it so the user has to write __do_not_type_this: Default::default().__do_not_type_this in order to avoid the FRU – anybody who does that deserves what they get when their code breaks. More concretely, you use a non-exported type from a private module which can only be created within YourType::default().


Anyway, I hope that made some sense, even though I didn’t explain it that thoroughly. Feel free to reach out to me and ask for clarification if you’re interested.

  1. It actually moves each field rather than copies the struct (e.g. it does not require the type implement Copy), but colloquially it “copies”.