How to Use the Sealed Trait Pattern in Rust

Rust does not support the sealed trait pattern natively, so you cannot restrict trait implementations to a single crate.

The library author's dilemma

You are building a graphics library. You expose a Shape trait so users can draw polygons, circles, and lines. You want users to be able to call shape.draw(), but you do not want them adding new shapes. Your rendering engine has a hardcoded list of supported types. If a user adds a Triangle implementation, your internal logic breaks because the engine never learned how to rasterize triangles.

In C#, you would write sealed interface Shape. In Kotlin, you'd use sealed interface. Rust has no such keyword. The language does not provide a built-in way to lock a trait to a specific crate. If you define a public trait, any crate can implement it.

Rust solves this by turning the module system against the trait. You use privacy rules to create a gate that only your code can pass. The result is a trait that behaves exactly like a sealed trait: the compiler rejects any attempt to implement it from outside your crate.

The keycard system

Think of a high-security building. The door opens for anyone holding a valid keycard. The door mechanism is public; anyone can see how it works. But the keycards are programmed by a master system inside the building. You can try to make a keycard at home, but without access to the master system, your card will never work.

In Rust, the public trait is the door. The private supertrait is the master system. You define a public trait that requires a private trait as a bound. External code can see the public trait, but it cannot implement the private trait because the module is private. Without the private trait, the public trait cannot be implemented. The door stays locked.

The pattern in code

The standard implementation uses a private module containing a private trait. The public trait lists the private trait as a supertrait.

// The sealed module is private. External crates cannot see inside.
mod sealed {
    // This trait is public within the module, but the module is private.
    // External code cannot access this trait at all.
    pub trait Sealed {}
}

// The public trait requires the private supertrait.
// This is the gate. You cannot implement Shape without implementing Sealed.
pub trait Shape: sealed::Sealed {
    fn draw(&self);
}

The convention is strict. Name the module sealed. Name the trait Sealed. The Rust community follows this naming universally. It signals intent instantly. If you see mod sealed, you know the trait is restricted. Do not fight the convention. Use the standard names so other developers recognize the pattern immediately.

How the compiler enforces the seal

When a user tries to implement your trait, the compiler checks the bounds. It sees that Shape requires sealed::Sealed. The user's code must provide an implementation for sealed::Sealed.

The user writes impl sealed::Sealed for MyTriangle. The compiler rejects this with E0603 (private trait). The module sealed is not accessible from the user's crate. The user cannot implement the supertrait. The user cannot implement the public trait. The attempt fails at compile time.

// This code lives in a different crate.
use my_graphics_lib::Shape;

struct Triangle;

// The compiler rejects this.
// error[E0277]: the trait bound `Triangle: sealed::Sealed` is not satisfied
//   --> src/main.rs:5:6
//    |
// 5  | impl Shape for Triangle {
//    |      ^^^^^ the trait `sealed::Sealed` is not implemented for `Triangle`
//    |
// note: required by `Shape`
//   --> my_graphics_lib/src/lib.rs:6:19
//    |
// 6  | pub trait Shape: sealed::Sealed {
//    |                   ^^^^^^^^^^^^ unsatisfied trait bound introduced here

The error message points to the supertrait bound. The user sees that Shape requires something they cannot provide. The seal holds.

Trust the module system. Privacy is the mechanism. The pattern is just syntax that leverages privacy to restrict implementations.

Why Rust lacks a native sealed keyword

Rust prioritizes open extensibility. Traits are designed to be implemented by anyone. This flexibility allows for powerful composition and plugin architectures. A native sealed keyword would add complexity to the trait solver and the language specification. It would introduce a special case where the rules of implementation change based on a keyword.

The language designers chose to keep the trait system uniform. If you want to restrict implementations, you use existing features. The module system is already powerful enough. The sealed trait pattern proves that you do not need new syntax. You only need to combine privacy and supertraits.

This design choice aligns with Rust's philosophy. The language provides the tools. You compose them to solve your problem. The result is often more flexible than a hardcoded keyword. You can adjust the seal by changing visibility, or you can expose the sealed trait for testing in dev dependencies. A keyword would be rigid. The pattern adapts.

Realistic usage and API evolution

The sealed trait pattern shines when you need to evolve an API without breaking semver. Adding a method to a public trait is a breaking change. Every external implementation must update to provide the new method. If users have not updated, their code fails to compile.

If the trait is sealed, no external implementations exist. You can add methods to the trait in a minor version bump. No user code breaks because no user code implements the trait. You gain the freedom to extend the interface safely.

mod sealed {
    pub trait Sealed {}
}

pub trait Shape: sealed::Sealed {
    fn draw(&self);
    // We can add this method later without breaking external code.
    // No external crate implements Shape, so no one needs to implement area().
    fn area(&self) -> f64;
}

// Internal implementation.
pub struct Circle {
    radius: f64,
}

impl sealed::Sealed for Circle {}

impl Shape for Circle {
    fn draw(&self) {
        println!("Drawing circle with radius {}", self.radius);
    }

    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
}

The library author controls the entire surface area of the trait. Users can use Shape as a trait object or pass it to functions, but they cannot add new types. The API remains stable and extensible only for the author.

This pattern also helps with exhaustiveness in complex scenarios. If you use Any for downcasting or maintain a registry of types, sealing the trait ensures that only known types can enter the system. You prevent runtime panics caused by unknown implementations.

Don't rely on comments. The compiler is the only enforcer that never sleeps.

Pitfalls and common mistakes

The pattern is simple, but small mistakes break the seal.

Re-exporting the sealed trait destroys the restriction. If you write pub use sealed::Sealed; in your crate root, external crates can access the trait. They can implement it. The seal is broken. Keep the module private. Never re-export the contents of sealed.

Forgetting the supertrait bound leaves the trait open. If you define pub trait Shape { fn draw(&self); } without the bound, the trait is public and open. Users can implement it. The mod sealed block becomes dead code. Always verify that the supertrait bound is present on the public trait.

Using the pattern for every trait adds noise. Sealed traits are a restriction. Use them only when you need to prevent external implementations. If you want users to extend your library, leave the trait open. Sealing is a tool for control, not a default style.

Decision matrix

Use a sealed trait when you need to expose a trait interface but restrict implementations to your crate, and you want the compiler to enforce the restriction. The pattern prevents external code from adding implementations, protecting your internal invariants and allowing safe API evolution.

Use an enum when the set of variants is fixed and known at compile time, and you do not need trait objects or external extensibility. Enums are simpler, faster, and give you pattern matching for free. Reach for sealed traits only when you need trait objects or the type hierarchy is too complex for a single enum.

Use documentation when the restriction is soft, and you are okay with users implementing the trait if they really want to. A comment in the docs is enough if the risk is low and you are building a framework where plugin authors might need to add implementations.

Use the #[doc(hidden)] method trick when you need to seal a trait but also want to avoid supertrait bounds for complex trait solver reasons. This variant adds a method returning a private type instead of a supertrait. It is functionally identical but rarely needed. Stick to the supertrait pattern unless you hit a specific solver limitation.

Where to go next