
Rust Sealed Traits: Designing Extensible APIs Without Breaking Your Abstractions
What a sealed trait is
A sealed trait is a public trait that inherits from a private trait. Because the private trait is not accessible outside the crate, external code cannot implement the public trait either.
The core idea is simple:
- expose the trait for method calls and bounds
- hide the “implementation permission” behind a private module
- keep control over which types can implement the trait
This is not a language feature; it is a design pattern built from Rust’s visibility rules.
Why it matters
Without sealing, a public trait becomes a contract with the entire ecosystem. Any future change can become a breaking change if someone has implemented that trait in a way your new design does not support.
Sealing is useful when:
- you want to define a closed set of implementors
- you expect to add methods later
- you need to preserve internal invariants
- you are modeling a protocol, state machine, or backend abstraction
- you want to provide extension points only through controlled wrappers
The basic pattern
Here is the canonical structure:
mod sealed {
pub trait Sealed {}
}
pub trait Parser: sealed::Sealed {
fn parse(&self, input: &str) -> Result<String, String>;
}
pub struct JsonParser;
impl sealed::Sealed for JsonParser {}
impl Parser for JsonParser {
fn parse(&self, input: &str) -> Result<String, String> {
Ok(format!("json: {input}"))
}
}External crates can use Parser, but they cannot implement it because they cannot name or implement sealed::Sealed.
What this buys you
You can safely add new methods to Parser later, because you control every implementor. You can also assume that all implementations obey the invariants established by your crate.
A practical example: a database backend API
Suppose you are writing a storage library that supports a few built-in backends. You want users to select a backend and call a common interface, but you do not want arbitrary third-party implementations.
mod sealed {
pub trait Sealed {}
}
pub trait Backend: sealed::Sealed {
fn name(&self) -> &'static str;
fn connect(&self) -> Result<(), BackendError>;
}
#[derive(Debug)]
pub struct BackendError;
pub struct Postgres;
pub struct Sqlite;
impl sealed::Sealed for Postgres {}
impl sealed::Sealed for Sqlite {}
impl Backend for Postgres {
fn name(&self) -> &'static str {
"postgres"
}
fn connect(&self) -> Result<(), BackendError> {
Ok(())
}
}
impl Backend for Sqlite {
fn name(&self) -> &'static str {
"sqlite"
}
fn connect(&self) -> Result<(), BackendError> {
Ok(())
}
}Now your public API can accept impl Backend:
pub fn initialize<B: Backend>(backend: B) -> Result<(), BackendError> {
backend.connect()
}This gives you a clean interface while preserving control over supported backends.
When sealing is the right choice
Sealed traits are not a default solution for every trait. They are best used when the trait is part of a controlled abstraction rather than an open extension point.
Good fits
| Use case | Why sealing helps |
|---|---|
| Built-in enum-like behavior | Keeps the set of valid implementations closed |
| Internal protocol layers | Prevents unsupported wire formats or state transitions |
| Library backends | Ensures all implementations satisfy hidden invariants |
| Public helper traits | Lets you add methods later without breaking external impls |
| Marker traits with semantics | Avoids accidental misuse by downstream crates |
Poor fits
| Use case | Why sealing is a bad fit |
|---|---|
| Plugin ecosystems | Users need to implement the trait |
| Framework extension points | Third-party integration is the goal |
| Public abstraction layers meant for composition | Sealing blocks legitimate customization |
If your trait is intended as a stable extension surface, do not seal it. Sealing is about control, not convenience.
Sealing and API evolution
One of the strongest reasons to seal a trait is future compatibility.
Imagine you publish this trait:
pub trait Serializer {
fn serialize(&self, value: &str) -> String;
}Later, you want to add:
fn format_hint(&self) -> Option<&'static str>;If external crates implement Serializer, your new method may require a default implementation, or it may break existing implementations. With a sealed trait, you can add methods more freely because you own every impl.
Design benefit
Sealing lets you make stronger assumptions about:
- method semantics
- return value constraints
- object lifecycle
- internal caching strategies
- compatibility with future features
This is especially valuable in libraries that need long-term stability.
Combining sealing with enums and exhaustive matching
Sealed traits often complement enums. A common pattern is to expose a trait for behavior while keeping the actual implementors limited to a known set of types.
For example, a parser API might support only a few formats:
JsonParserYamlParserTomlParser
You can use a sealed trait to ensure those are the only valid implementations while still allowing polymorphic code:
pub fn run_parser<P: Parser>(parser: P, input: &str) -> Result<String, String> {
parser.parse(input)
}This gives you generic dispatch without opening the trait to arbitrary downstream code.
Sealed traits with associated types
Sealing is especially useful when a trait has associated types that encode internal assumptions.
mod sealed {
pub trait Sealed {}
}
pub trait Storage: sealed::Sealed {
type Key;
type Value;
fn get(&self, key: &Self::Key) -> Option<Self::Value>;
}If external code could implement Storage, it might choose incompatible Key or Value types that violate your intended model. Sealing ensures that only your crate defines the allowed combinations.
This is a common technique in libraries that model:
- typed caches
- message codecs
- database rows
- platform-specific backends
A common alternative: private fields instead of sealed traits
Sometimes developers use private fields in a struct to prevent external construction or extension. That works for types, but not for trait implementations.
Here is the difference:
- private fields control how a type is instantiated
- sealed traits control who can implement behavior
They solve different problems. If your goal is to preserve trait invariants, sealing is the right tool.
Best practices for implementing sealed traits
Keep the sealing module private
The sealed module should not be part of your public API surface. It should be hidden and minimal.
mod sealed {
pub trait Sealed {}
}Do not add extra public items there unless they are truly internal implementation details.
Make the public trait inherit from the sealed trait
This is the key enforcement mechanism:
pub trait MyTrait: sealed::Sealed {
// methods
}If you forget this inheritance, the pattern does not work.
Implement the sealed trait only for supported types
Every type that implements the public trait must also implement the sealed trait. Keep those impls close together so the relationship is obvious.
Document the intent
Even though the compiler enforces the restriction, your API docs should explain why the trait is sealed. This helps users understand that the limitation is intentional, not accidental.
A short note like “This trait is sealed and cannot be implemented outside this crate” is usually enough.
Sealed traits and blanket implementations
Be careful with blanket impls. They can accidentally widen your API in ways that undermine the purpose of sealing.
For example:
impl<T> sealed::Sealed for T {}This would effectively make every type implement the sealed trait, defeating the pattern entirely. Avoid blanket impls unless they are tightly constrained and still preserve the intended restriction.
A safer approach is to implement the sealed trait only for concrete types you control.
Comparison: sealed vs open traits
| Trait style | External impls allowed | API evolution flexibility | Best for |
|---|---|---|---|
| Open trait | Yes | Lower | Plugins, user extensions, framework hooks |
| Sealed trait | No | Higher | Controlled abstractions, internal invariants, stable backends |
This tradeoff is the heart of the decision. If you need openness, keep the trait open. If you need control, seal it.
Testing and maintenance considerations
Sealed traits can simplify testing because you know the exact set of implementors. That makes it easier to write exhaustive tests for behavior and edge cases.
However, there is a maintenance cost: if you later decide to open the trait, that is a breaking API change in the opposite direction. Users may have built assumptions around the trait being closed. Treat the decision as part of your public contract.
Practical guidance
- seal traits that represent internal policy or protocol rules
- leave extension traits open when user customization is the primary goal
- prefer sealed traits for “supported implementations only” APIs
- avoid sealing just to discourage usage; use documentation for that
A real-world design checklist
Before sealing a trait, ask:
- Will third-party implementations be genuinely useful?
- Do I need freedom to add methods later?
- Are there hidden invariants that external code cannot safely uphold?
- Is the trait part of a closed set of supported behaviors?
- Would a plugin model be better served by an open trait?
If the answers lean toward control and stability, sealing is likely appropriate.
Conclusion
Sealed traits are a small pattern with a large impact on API design. They let you publish behavior without surrendering control over who can implement it. That makes them ideal for libraries that need stable abstractions, controlled backends, or room to evolve safely.
Used well, sealing helps you design Rust APIs that are both ergonomic for consumers and maintainable for library authors. It is one of the most practical tools for balancing extensibility with long-term correctness.
