#[non_exhaustive] keeps your enums and structs open to future variants and fields;Learn Rust Series):At the end of last episode I said we would use the same instinct -- encoding intent into types -- to shape the public face of a library, so that outsiders can use your traits but cannot break the invariants you depend on. Today we make good on that. Everything in the series so far has been about writing Rust. This episode is about writing Rust that other people build on top of, where one careless design decision quietly becomes a breaking change that ripples out across every crate that ever depended on you.
Here is the whole episode in a sentence, and then we unpack it slowly. When you publish a type, you make two very different kinds of promise -- "you may use this" and "you may extend this" -- and the trouble starts when you make the second promise by accident. Two tools, sealed traits and #[non_exhaustive], let you say exactly which promise you meant, so you can keep improving a library for years without breaking the code that was written against version one. This is the difference between a crate people trust to upgrade and a crate people pin to an old version and never touch again.
Let me clear last episode's homework first, as always, and then we go from the problem, to the pattern, to the day the trait needs to grow ;-)
Episode 27 was generic associated types and lending iterators -- an associated type that carries its own lifetime, written type Item<'a>, so an iterator can lend out a view into a buffer it reuses. Three exercises, and here is each with full, runnable code.
Exercise 1 asked for a for_each default method on LendingIterator that takes a closure and calls it on each item, used on Counter to print 1, 2, 3:
trait LendingIterator {
type Item<'a> where Self: 'a;
fn next(&mut self) -> Option<Self::Item<'_>>;
fn for_each<F: FnMut(Self::Item<'_>)>(mut self, mut f: F) where Self: Sized {
while let Some(item) = self.next() {
f(item);
}
}
}
struct Counter { n: u32, max: u32 }
impl LendingIterator for Counter {
type Item<'a> = u32 where Self: 'a;
fn next(&mut self) -> Option<u32> {
if self.n < self.max { self.n += 1; Some(self.n) } else { None }
}
}
fn main() {
Counter { n: 0, max: 3 }.for_each(|x| println!("{x}")); // 1, 2, 3
}
The closure bound is FnMut(Self::Item<'_>), and that elided '_ is the key: it ties the closure's argument to the lifetime of one next call, so the default method works whether the item is owned (as here) or a borrowed view. where Self: Sized is there because for_each consumes self by value, exactly the marker-trait care we learned in episode 25.
Exercise 2 wanted a Multiples { store: [i32; 4], factor: i32, count: u32 } that, each call, fills store with the next four multiples of factor and lends back a &[i32] view -- reusing the one buffer like Rolling did:
trait LendingIterator {
type Item<'a> where Self: 'a;
fn next(&mut self) -> Option<Self::Item<'_>>;
}
struct Multiples { store: [i32; 4], factor: i32, count: u32 }
impl LendingIterator for Multiples {
type Item<'a> = &'a [i32] where Self: 'a;
fn next(&mut self) -> Option<&[i32]> {
if self.count == 0 { return None; }
self.count -= 1;
let mut v = self.store[3] + self.factor; // continue past the last value written
for k in 0..4 {
self.store[k] = v;
v += self.factor;
}
Some(&self.store)
}
}
fn main() {
let mut m = Multiples { store: [0; 4], factor: 10, count: 3 };
while let Some(view) = m.next() {
println!("{view:?}"); // [10, 20, 30, 40], [50, 60, 70, 80], [90, 100, 110, 120]
}
}
The whole point is that there is exactly one buffer. Each call overwrites self.store in place and hands back a &[i32] view of it, so no allocation happens per step, and the next value continues from store[3] (the last number written last time). Just like Rolling, you drive it with while let, not for, because a lending iterator is not the standard Iterator.
Exercise 3 asked you to try writing Windows as a plain Iterator whose Item borrows from self, and watch it get rejected. It cannot be done: Iterator::Item is a single fixed type that has no way to name the lifetime of the &mut self borrow inside next, so you cannot even write the type Item = ... line -- the lifetime you would need does not exist at that point. That missing vocabulary is precisely the gap generic associated types were built to fill, and feeling the wall yourself is the best way to understand why GATs had to be invented. Right, homework cleared -- now for the public face of a library ;-)
When you make a trait pub, you are promising the whole world something bigger than you might realise. You are promising that anyone can implement it for their types. And that promise ties your hands in a way that only bites months later, when your library has users.
Cast your mind back to episode 23, where we met coherence and the orphan rule. The compiler works hard to guarantee there is at most one implementation of a trait for a type, because two impls would be a genuine ambiguity. But coherence says nothing about who is allowed to add implementations in the first place. If your trait is public, a downstream crate can implement it for its own types the moment they import it. From that instant, those external impls are part of your trait's contract whether you wanted them or not.
Now suppose a release or two later you want to add a method to the trait. Watch what happens:
pub trait Encoder {
fn encode(&self) -> Vec<u8>;
fn name(&self) -> &str; // added in v2 -- and now every external impl breaks
}
Every crate that implemented Encoder for its own type now fails to compile, because their impls are missing name. You could give name a default body to soften the blow, but that is a workaround, not a fix, and it does not help at all when the new method genuinely has no sensible default. Adding a required method to a public, openly-implementable trait is a breaking change, and by the rules of semantic versioning (episode we touched on with cargo, back in the modules work) that forces a major version bump. Major bumps are expensive: your users have to opt in, some never do, and the ecosystem fragments across versions.
Here is the thing though -- sometimes you never intended outsiders to implement the trait at all. Often a trait exists only to describe a fixed, closed set of types that your own crate provides. Think of a trait that abstracts over "the three compression formats this library supports" or "the two byte orders". You want callers to write generic code against the trait, but you have no interest in letting a stranger bolt on a fourth format you have never tested. For traits like that, you want to seal them.
Sealing works by giving your public trait a supertrait that is public in name but impossible to implement from outside your crate. The trick leans entirely on module privacy from episode 9: you put a marker trait inside a private module, so its name leaks out through your public trait's bound, but the trait itself is unreachable to anyone who tries to write an impl for it.
mod sealed {
pub trait Sealed {} // the trait is pub, but the module `sealed` is private
}
pub trait Shape: sealed::Sealed {
fn area(&self) -> f64;
}
pub struct Circle { pub radius: f64 }
pub struct Square { pub side: f64 }
impl sealed::Sealed for Circle {}
impl sealed::Sealed for Square {}
impl Shape for Circle {
fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
}
impl Shape for Square {
fn area(&self) -> f64 { self.side * self.side }
}
fn main() {
let c = Circle { radius: 2.0 };
let s = Square { side: 3.0 };
println!("{:.2} {:.2}", c.area(), s.area()); // 12.57 9.00
}
Walk through the mechanism, because it is subtle and worth getting exactly right. The sealed module is not marked pub, so from outside the crate nobody can even name sealed::Sealed -- the path does not resolve. Inside your crate, you implement Sealed for each type you want to bless (Circle and Square), and only then implement Shape. Because Shape requires Sealed as a supertrait, any type that wants to implement Shape must first implement Sealed. And since Sealed lives behind a private door, a downstream crate simply cannot do that step. If someone in another crate writes impl Shape for TheirType, the compiler complains that TheirType does not implement sealed::Sealed -- and there is no way for them to fix it, because the trait they would need is not reachable. You now own the complete, final set of Shape implementors, forever.
The important part is that this costs your users nothing in the common case. They can still call area, still store a Box<dyn Shape> (episode 15), still write functions generic over T: Shape. The only thing they cannot do is add a new implementor. That is exactly the boundary we wanted.
It is worth hammering this home, because "sealed" sounds restrictive and people assume it locks users out of writing generic code. It does not. A sealed trait behaves like any other trait at the call site -- the seal only affects who may implement it, never who may use it:
mod sealed { pub trait Sealed {} }
pub trait Unit: sealed::Sealed {
fn symbol(&self) -> &str;
}
pub struct Meter;
pub struct Second;
impl sealed::Sealed for Meter {}
impl sealed::Sealed for Second {}
impl Unit for Meter { fn symbol(&self) -> &str { "m" } }
impl Unit for Second { fn symbol(&self) -> &str { "s" } }
fn describe<U: Unit>(u: &U) -> String {
format!("unit: {}", u.symbol())
}
fn main() {
println!("{}", describe(&Meter)); // unit: m
println!("{}", describe(&Second)); // unit: s
}
describe is generic over U: Unit and works exactly as you would expect. A downstream crate can write its own describe-like functions all day long. What it cannot do is invent a Furlong type and make it a Unit -- and that is the whole guarantee you were after. You keep the closed set; your users keep the ergonomics.
Now the payoff, and the reason we started with that broken Encoder. Because you control every implementation of a sealed trait, adding a method to it is no longer a breaking change. The only impls that exist are yours, so you update them in the very same release, and no downstream code notices:
mod sealed { pub trait Sealed {} }
pub trait Shape: sealed::Sealed {
fn area(&self) -> f64;
fn perimeter(&self) -> f64; // added later -- NOT a breaking change, because we own all impls
}
pub struct Square { pub side: f64 }
impl sealed::Sealed for Square {}
impl Shape for Square {
fn area(&self) -> f64 { self.side * self.side }
fn perimeter(&self) -> f64 { 4.0 * self.side }
}
fn main() {
let s = Square { side: 2.0 };
println!("{} {}", s.area(), s.perimeter()); // 4 8
}
Compare the two worlds. With an open Encoder, adding name broke everyone. With a sealed Shape, adding perimeter breaks no one, because there is no external impl left un-updated -- you patched all of them yourself before shipping. The trait can grow method after method, release after release, and every version stays a minor bump. That is a genuinely powerful position for a library author to be in.
The trade is deliberate, and you should say it out loud when you make it: you give up letting users extend the trait, in exchange for the freedom to change it. Make that trade when the trait describes your types and the set is meant to be closed. Decline it -- leave the trait wide open -- when the entire point is user extension.
The decision hinges on one question: is this trait a closed vocabulary you provide, or an extension point you offer? Both are legitimate, and the standard library shows you both.
Leave it open when extension is the point. std::fmt::Display, std::iter::Iterator, std::error::Error -- these exist precisely so that you implement them for your types. Sealing them would defeat their entire reason for existing. If your trait is "here is a behaviour, plug your type in", never seal it.
Seal it when the trait is really an internal detail dressed up as a bound. A classic case is a trait that unifies a handful of primitive-ish types so you can write one generic function over them -- "all the integer widths this codec understands", say. You expose the trait only so users can name it in a where clause; you never meant them to add members. Sealing keeps that promise honest and keeps your hands free. Plenty of real crates do this: their docs literally say "this trait is sealed and cannot be implemented outside this crate", and now you know the exact mechanism behind that sentence.
There is a lighter-weight cousin worth a mention. If you only want to stop users naming or matching every case, and the thing is an enum or a struct rather than a trait, you do not need the sealed-supertrait dance at all. You reach for #[non_exhaustive] instead -- so let us meet it.
The sibling tool applies to enums and structs. Marking a type #[non_exhaustive] tells other crates, right there in the type's definition, that it may gain more variants or fields in future versions, so they must not assume they have seen the complete set. For an enum, the effect is that downstream match expressions are forced to include a wildcard arm:
#[non_exhaustive]
pub enum ApiError {
NotFound,
Timeout,
}
fn describe(e: &ApiError) -> &str {
match e {
ApiError::NotFound => "not found",
ApiError::Timeout => "timed out",
}
}
fn main() {
println!("{}", describe(&ApiError::Timeout)); // timed out
}
Notice that inside the defining crate you still match exhaustively, with no wildcard -- that is exactly what we do here, and it compiles fine. The restriction is only felt by other crates. When a downstream crate matches on ApiError, the compiler refuses to accept the match unless it has a _ => arm, precisely because from their side the enum is "open" and might grow. Which means when you later add a RateLimited variant, their code keeps compiling -- their wildcard already handles it. Adding a variant becomes a non-breaking change.
Code that a downstream crate is required to write looks like this, with the future-proofing wildcard:
#[non_exhaustive]
pub enum Status { Ok, Pending }
fn label(s: &Status) -> String {
match s {
Status::Ok => "ok".to_string(),
Status::Pending => "pending".to_string(),
other => format!("unhandled: {}", variant_index(other)), // required by non_exhaustive
}
}
fn variant_index(s: &Status) -> u8 {
match s {
Status::Ok => 0,
Status::Pending => 1,
_ => u8::MAX,
}
}
fn main() {
println!("{}", label(&Status::Ok)); // ok
}
That catch-all arm is exactly what the attribute forces on consumers, and it is why #[non_exhaustive] makes adding a variant safe: existing code already routes any unknown variant somewhere sensible instead of failing to compile. This is how the standard library and mature crates keep extending their error enums for years without a major version bump.
For a struct, #[non_exhaustive] does something slightly different but with the same goal. It stops downstream code from constructing the struct with the literal Name { field: ... } syntax, forcing them through a constructor or builder you provide instead:
#[non_exhaustive]
pub struct Config {
pub retries: u32,
pub verbose: bool,
}
impl Config {
pub fn new() -> Config {
Config { retries: 3, verbose: false }
}
pub fn with_retries(mut self, n: u32) -> Config {
self.retries = n;
self
}
}
fn main() {
let cfg = Config::new().with_retries(5); // downstream must go through these, not a literal
println!("{} {}", cfg.retries, cfg.verbose); // 5 false
}
Why does forbidding the struct literal help? Because a struct literal names every field. If a downstream crate wrote Config { retries: 3, verbose: false } and you later added a timeout field, their literal would suddenly be missing a field and fail to compile. By routing them through Config::new() instead, you can add all the fields you like -- the constructor fills in sensible defaults, and old callers keep working untouched. Inside your own crate you can still use the literal freely (as new does here), because, just like the enum case, the restriction only applies across a crate boundary.
The through-line of the whole episode is a single word: intent. For every public item you ship, decide deliberately whether users should be able to extend it or only to use it. Seal the traits whose implementor set you want to own; mark #[non_exhaustive] the enums and structs you expect to grow. When you get this right, upgrading your library is a non-event for your users, and that is the quiet mark of a well-designed API.
Since quite some of you came through the Learn Python Series first (as did I), a look sideways sharpens the picture -- and here the split is less "flexible vs strict" and more "who even bothers".
Python does not, and largely cannot, seal anything. Any class is subclassable, any method overridable, and the "closed set of types" idea is enforced by convention and documentation, not the language:
class Shape:
def area(self):
raise NotImplementedError
# Nothing stops a downstream user from doing this, ever:
class Triangle(Shape):
def area(self):
return 0.5 * 3 * 4
print(Triangle().area()) # 6.0 -- Python happily accepts the new subclass
There is typing.final as a hint to type checkers, but at runtime it is toothless. So a Python library author who wants a closed set relies on a comment saying "please do not subclass this" and hopes for the best. It usually works, right up until it does not.
Go, interestingly, has the closest parallel to Rust's sealed trait, and it uses the very same idea -- an unexported member that outsiders cannot satisfy:
package shape
type Shape interface {
Area() float64
sealed() // unexported method: only this package can implement Shape
}
type Circle struct{ Radius float64 }
func (c Circle) Area() float64 { return 3.14159 * c.Radius * c.Radius }
func (c Circle) sealed() {}
Because sealed() starts with a lowercase letter it is unexported, so no other package can write a method with that name into their type, so no other package can satisfy the interface. That is exactly Rust's private-supertrait trick wearing Go clothing -- privacy standing in for a language feature. Java sits somewhere in the middle: it gained actual sealed classes and interfaces in Java 17 with an explicit permits clause, which is more first-class than either but younger. Three points on a familiar line: Python asks nicely, Go and Rust use privacy to make the guarantee real, and Java bakes it into a keyword. I know which I trust when a downstream crate I have never heard of might otherwise wander into my invariants at 2am ;-)
pub trait Sealed {} inside a non-pub module), so outsiders can use the trait but cannot implement it, leaving you owning the complete set of implementors.Display, Iterator, Error).#[non_exhaustive] is the sibling tool for enums and structs: it forces downstream match to carry a wildcard and forbids downstream struct literals, so you can add variants and fields later without breaking anyone.The deeper current, running through the last several episodes, is that Rust keeps handing you ways to say precisely what you mean in the type system and then have the compiler enforce it -- values baked into types with const generics, lifetimes woven into associated types with GATs, and now the boundary between "use me" and "extend me" written down where the compiler can hold the line. Once you are comfortable telling the type checker who may do what, the natural next step is telling it when each operation is allowed -- encoding a type's changing state so that calling the wrong method at the wrong moment simply will not compile. We will use exactly this instinct next time to make whole categories of misuse unrepresentable. One idea at a time ;-)
Three exercises as always, from gentle to chewier. Full solutions open the next episode, so genuinely have a go first -- typing it yourself is where it sticks.
Format trait with a pub trait Sealed {} in a private module, implement it for two of your own unit structs (say Json and Xml), then try to add impl Format for String without implementing the sealed supertrait and read the error the compiler gives you.#[non_exhaustive] struct a builder-style constructor (a new plus one with_* method), then add a brand-new field to the struct and confirm the existing constructor callers keep compiling without change. Note which line would have broken if you had allowed struct-literal construction instead.Bedankt voor het meelezen tot hier, en tot de volgende aflevering! ;-)