The Sealed Trait Pattern in Rust
Another day, another Rust pattern. This time about sealed traits - a pattern that comes in handy whenever you want to prevent external users from implementing a public trait.
I briefly touched upon it at the end of the article about trait-based domain constraints.
The Problem: Not All Traits Should Be Implementable
I’ll illustrate the problem using a practical example.
A common operation is reinterpreting a Rust value as raw bytes, for example to cross the FFI boundary into C, or to send data over the network.
I’ve personally had to do that a lot when designing multiplayer networking for an ongoing project (more on that later), for example when sending game state over TCP.
This is a notoriously difficult problem, for a host of reasons related to memory layout. For example, the compiler may insert padding between struct fields for alignment and optimization.
For that reason, we want our data to satisfy strict memory guarantees that the compiler cannot verify on its own. What we need is a trait to encode the contract. We’ll call it Pod, which stands for Plain Old Data - a term commonly employed for a type with a stable, predictable memory layout:
pub unsafe trait Pod {}
pub fn as_bytes<T: Pod>(val: &T) -> &[u8] {
unsafe {
std::slice::from_raw_parts(
val as *const T as *const u8,
std::mem::size_of::<T>(),
)
}
}
Note that we made Pod an unsafe trait: the conversion performed by as_bytes() is unsafe by nature, but here we shift the burden of safety to the implementor; as long as the Pod implementation is known to be safe, users can use as_bytes() without worrying about undefined behavior.
But here’s the problem: Pod must be public to appear in a public function signature, which means anyone can implement it. We probably don’t want to allow that for downstream users, as an honest mistake could silently snowball into catastrophic failure.
Instead we want to keep the unsafe impl strictly under the library’s control where every implementation can be audited. What we want is a sealed trait.
The Sealed Trait Pattern
The idea is simple but powerful: add a private supertrait bound to our public Pod trait:
mod private {
pub trait Sealed {}
}
pub unsafe trait Pod: private::Sealed {}
Pod now has private::Sealed as a supertrait, meaning implementing Pod requires implementing Sealed first.
But the Sealed trait is confined to the private module. Rust’s module visibility rules dictate that a private item may only be accessible by the current module and its descendants.
Since Sealed is inaccessible from outside the crate, no one outside it can implement it, and by extension can’t implement Pod.
In other words, only the crate can implement Pod.
We then implement Sealed only for the types we have personally audited and verified:
mod private {
pub trait Sealed {}
impl Sealed for f32 {}
impl Sealed for u32 {}
impl Sealed for i32 {}
// ... other primitives
}
pub unsafe trait Pod: private::Sealed {}
unsafe impl Pod for f32 {}
unsafe impl Pod for u32 {}
unsafe impl Pod for i32 {}
// ... other primitives
Any attempt from a downstream crate to implement Pod now fails, and the unsafe contract is enforced by the compiler.
A Note on Visibility
You might wonder how a trait like Pod can be made public if it is bound to a private supertrait like Sealed.
Rust does in fact expose its path, but in a constrained way: the name private::Sealed surfaces in documentation and compiler diagnostics, but a user still cannot name or implement it.
Rust allows private types to appear as supertrait bounds precisely to enable this pattern, while still enforcing access control at the implementation level.
In practice, I would typically keep the path clean by putting Sealed in its own module, then make it available throughout the crate with use at the crate root - that way, the entire crate can use the shorter path Sealed instead of private::Sealed, and it appears in the documentation as:
pub trait Pod: Sealed { }
When to Use Sealed Traits
I see two main situations where sealed traits prove useful.
The first, as we discussed, is when implementations carry invariants that the compiler cannot verify, and an incorrect implementation would cause undefined behavior. Sealing a trait means only the library author can create implementations, ensuring each one adheres to a rigorous auditing process. The unsafe keyword communicates the danger, and the seal enforces it.
The second situation is forward-compatibility. Sealed traits provide assurance that if you later introduce new methods to the trait, it won’t constitute a breaking change; no one outside the crate has implementations that would suddenly become incomplete. Many traits in the standard library are sealed for this reason, even when safety isn’t a concern.
A sealed supertrait provides a zero-cost mechanism to draw a strict boundary around who gets to implement what.