Sealed Traits and API Evolution

Once code leaves your crate, every public item becomes a promise. Modules and Project Organization covered what pub exposes; this tutorial covers the consequence: a public API is a contract, and changing it can break every downstream crate. The skill is designing APIs that can grow without a major-version bump, leaving yourself room to add a struct field, an enum variant, or a trait method later without it being a breaking change. Two tools do most of the work: the sealed-trait pattern and #[non_exhaustive].


The Problem: Public Means You Can't Take It Back

In semver, a breaking change forces a major version bump (1.x → 2.0) and makes downstream users edit code to upgrade. The trap is that many innocent-looking additions are breaking. Adding a field to a struct that users construct with a literal breaks every such construction:

// v1.0 — users write: Config { host, port }
pub struct Config {
    pub host: String,
    pub port: u16,
}

// v1.1 — adding a field BREAKS every `Config { host, port }` literal downstream
pub struct Config {
    pub host: String,
    pub port: u16,
    pub timeout: Duration,   // existing literals no longer compile: "missing field timeout"
}

The same hazard applies to adding an enum variant (downstream match statements that were exhaustive now aren't) and to adding a required method to a public trait (every external impl breaks). The fix isn't to never change anything, it's to design the original version so these additions are non-breaking from the start.


#[non_exhaustive]: Reserve the Right to Add

#[non_exhaustive] on a struct or enum tells downstream crates "more may be added later, don't assume this is the complete set." It changes how outside code can use the type, in exactly the ways that would otherwise break on a later addition:

#[non_exhaustive]
pub struct Config {
    pub host: String,
    pub port: u16,
}

#[non_exhaustive]
pub enum Error {
    NotFound,
    Timeout,
}

For a #[non_exhaustive] struct, downstream code can't use a struct literal to construct it (they must go through a constructor or builder you provide) and can't exhaustively destructure it without a ... For a #[non_exhaustive] enum, downstream match must include a wildcard _ => arm. Both restrictions exist so that when you later add a field or variant, existing downstream code still compiles, the .. and _ already account for the unknown.

Gotcha: #[non_exhaustive] only affects other crates, within the defining crate the type behaves normally (literals and exhaustive matches still work). This catches people off guard: your own tests construct Config { .. } fine, so it looks like nothing changed, but you've actually constrained every external user. Apply it deliberately to types you expect to grow (config structs, error enums), and provide a constructor or builder (Builder Pattern and Typestate) so downstream users have a forward-compatible way to build the type.


Sealed Traits: A Trait Others Can Use but Not Implement

A public trait is a double-edged sword: any downstream crate can write its own impl, which means you can never add a method without a default (doing so breaks every external impl), and you lose control over the set of implementing types. Sometimes that's what you want. Often it isn't, you want users to call the trait's methods and use it as a bound, but only you should implement it. The sealed trait pattern enforces exactly that.

mod private {
    pub trait Sealed {}          // visible only inside this crate
}

// public trait, but it requires the private supertrait
pub trait Shape: private::Sealed {
    fn area(&self) -> f64;
}

pub struct Circle { pub r: f64 }

impl private::Sealed for Circle {}   // only this crate can write this
impl Shape for Circle {
    fn area(&self) -> f64 { std::f64::consts::PI * self.r * self.r }
}

Because Shape requires private::Sealed as a supertrait (Trait Inheritance and Supertraits), and Sealed lives in a private module no downstream crate can name, outside crates can't satisfy the supertrait bound, so they can't implement Shape. They can still call .area(), store Box<dyn Shape>, and write T: Shape bounds, everything except adding new implementors.

The payoff is freedom to evolve: since you control every impl, you can add a method to a sealed trait without a default and it's not breaking, you just update your own impls. The standard library uses this exact pattern (e.g. the traits behind Index paths and several iterator-adapter traits) precisely to keep the right to extend them.


When to Seal, When to Leave Open

You want users toUse
Implement the trait for their own types (extension point)a normal open pub trait
Only call the trait / use it as a bound, never implement ita sealed trait
Construct your struct but tolerate future fields#[non_exhaustive] + a constructor/builder
match your enum but tolerate future variants#[non_exhaustive] enum
Freely add trait methods latera sealed trait, or give every method a default body

The decision hinges on a single question: is this an extension point or a closed contract? If the entire purpose of the trait is to let users plug in their own types (like serde::Serialize or std::iter::Iterator), leave it open, sealing it would defeat the point. If the trait is an internal vocabulary you merely expose for calling and bounding, seal it so you keep the right to grow it. The cost of guessing wrong is asymmetric: you can always unseal a trait later (that's non-breaking), but you can never seal an open one without a major version bump.


Key Takeaways

  • A public API is a semver contract. Innocent-looking additions, a new struct field, a new enum variant, a new trait method, are usually breaking changes that force a major version bump and downstream edits.
  • #[non_exhaustive] reserves the right to add fields/variants later: external crates can't use struct literals or exhaustive match without ../_, so a future addition won't break them. It affects other crates only, not the defining crate.
  • The sealed-trait pattern (a public trait with a private supertrait) lets users call and bound on the trait but not implement it, since they can't name the private supertrait. This keeps your right to add methods without a default.
  • Choose by intent: leave a trait open when it's an extension point (users supply types); seal it when it's a closed contract you merely expose. Pair #[non_exhaustive] types with a constructor/builder so users have a forward-compatible way to build them.
  • The cost of guessing is asymmetric: unsealing a trait or removing #[non_exhaustive] later is non-breaking, but sealing or restricting an already-open API requires a major bump, so lean toward reserving flexibility up front.