Exploring Type Erasure & Trait Objects in Rust
Nov 10, 2025 - ⧖ 25 minThe Problem
You've defined a flexible handler trait, perfect for tasks like processing web requests or messages. The goal is to collect all these handlers into an easily accessible "service." However, because your trait relies on associated types or generics, the powerful simplicity of trait objects is lost. We cannot store different concrete handlers in a single, unified collection.
The Build
For this post we'll build a small messaging system with two main components:
- Messages
- Messages can be sent into the system and are processed sequentially. They can be any type.
- Observers
- Observers will listen for messages and then perform some action. They can be any type.
It's important to note that observers will not have point-to-point communication. All observers are observing what happens in the system and then reacting as needed.
This post is primarily geared towards how to use type erasure. Therefore, we will keep the messaging system simple and single-threaded.
API Exploration
We'll start by creating how we would like to use our system, and then work backwards to try and implement the idea as closely as possible. Here is roughly how using a messaging system API might look:
// Create a system
let system = MessageSystem::default();
// Create an observer that prints a greeting
let observer = GreetingObserver::default();
// Add the observer to the system
system.add_observer(observer);
// Create a message
let msg = SayHello("world".to_string());
// Send it
system.send(&msg);
// We would expect that the GreetingObserver prints "Hello, world!"
This seems like a fairly decent way to use the system. Next we'll create a mock of how we want our observer code structured, and then we'll build everything this design:
// Let's make our message types while we're here in a code block...
pub struct SayHello(pub String);
pub struct SayGoodbye(pub String);
// An observer that tracks the number of times it printed a greeting.
#[derive(Default)]
pub struct GreetingObserver {
greet_count: u32,
}
impl GreetingObserver {
// This is how we want the handler to look.
fn handle(&mut self, hello: &SayHello)
{
// The observer can do whatever it wants here
self.greet_count += 1;
println!("Hello, {}!", hello.0);
}
}
This seems fairly straightforward and accomplishes what we want. A SayHello message comes in, handle processes the message, and the observer can mutate itself because the signature has &mut self.
Prototyping a system starting from basic parts has the added benefit of being easily testable (a characteristic that we'll try to maintain as we build the system):
#[test]
fn says_hello() {
// Given a default observer
let mut observer = GreetingObserver::default();
// When we send it a SayHello message
let msg = SayHello("world".to_string());
observer.handle(&msg);
// Then the greeting counter is incremented
assert!(observer.greet_count > 0);
}
Now we'll move on to modeling the Observer trait.
Observer Trait
We know from the previous section that we want a handle method on our observer. There is also the understanding that we'll add more messages as the system grows, and because the build states that it needs to work with any message type. So the trait will need to have a generic type parameter which will act as the message we want to observe.
If we copy paste that current handle method from the GreetingObserver, and throw it into a trait with a generic type parameter, here is what we get:
pub trait Observer<M> {
fn handle(&mut self, msg: &M);
}
There we go! Generic type parameter M will act as our message type. This will allow us to implement any number of message types for any given observer.
Let's go ahead and implement Observer on our GreetingObserver now. All we need to do is change the impl line:
// We implement the trait specifically for the `SayHello` message.
impl Observer<SayHello> for GreetingObserver {
// Below isis the same code as before
fn handle(&mut self, hello: &SayHello)
{
self.greet_count += 1;
println!("Hello, {}!", hello.0);
}
}
Let's also add another handler. It's always a good idea to have 2 things when building out a system like this because it helps reveal typing issues that would otherwise go unnoticed, especially when dealing with generics:
impl Observer<SayGoodbye> for GreetingObserver {
fn handle(&mut self, goodbye: &SayGoodbye) {
self.greet_count += 1;
println!("Goodbye, {}!", goodbye.0);
}
}
Now that we have created an observer and a trait, we need a messaging system. But before that we need to talk about the topic of this article: type erasure.
Type Erasure With Trait Objects
Type erasure is a mechanism where specific type information is removed or "erased" at runtime. Rust uses trait objects to accomplish this. Let's look at a small example of type erasure before building our messaging system.
We will create a trait that, when implemented correctly, prints out a message:
// Our trait
trait PrintName {
fn print_it(&self);
}
// An implementer
struct Foo;
impl PrintName for Foo {
fn print_it(&self) {
println!("foo");
}
}
// Another implementer
struct Bar;
impl PrintName for Bar {
fn print_it(&self) {
println!("bar");
}
}
Now let's introduce the "trait object" part by using a Box<dyn PrintName>:
#[derive(Default)]
struct Container {
// A collection of trait objects
reporters: Vec<Box<dyn PrintName>>,
}
The above code is saying that we will have a Vec of trait objects, and those objects will implement the PrintName trait. While looking at this code, we have no idea whether we will have a Foo or a Bar or some other future type. However, we do know that whatever type it happens to be, it must implement PrintName. Since all the types in the Vec will implement PrintName, we will be able to call print_it no matter what. This is type erasure: we don't know what the type is going to be, but we do know that we can call methods declared on the trait.
Let's try using it to make sure it works:
let mut container = Container::default();
// We need to `Box` the structs in order to create the trait objects:
container.reporters.push(Box::new(Foo));
container.reporters.push(Box::new(Bar));
for reporter in &container.reporters {
reporter.print_it()
}
// output:
// foo
// bar
Remember that Rust's type erasure happens at runtime. The compiler has no way to know whether a Foo or a Bar will be placed inside the Vec until the program runs. Vec (and other collection types) require each item to be the same size in order to correctly operate.
A pointer is always the same size for a given architecture. Therefore, when storing trait objects in a collection, you'll always need to wrap it with some kind of pointer. Box is typical, but Rc and Arc work as well depending on what you are trying to build and what you are doing with the trait objects.
Notes on performance
Since trait objects must always exist behind a pointer, there is always a performance hit when using them. Note that this only occurs when calling the erased methods (like when calling print_it).
Here's an example: let's say you have a video decoder application with multiple codecs. You put the decoders behind trait objects which makes it easy to add more later. Your trait uses something like fn decode(&self, frame: F) which gets called for every video frame. This does a lot of extra extra work continuously to lookup the decode for the trait object and will have a noticable performance penalty.
However, you can still get high performance in this situation using trait objects by putting all the heavy lifting into the trait object and then calling the erased method infrequently. So instead of creating an interface where you have to lookup the erased method every frame, you'd do something like this: fn decode(&self, stream: S). Only 1 lookup is needed to pass the video stream to the trait object, and then your decoder can continually decode frames by reading the stream. All the code inside the decode method will be plain functions, so this will achieve native performance while the video is being decoded.
Message System
Now let's get back on topic and start to work on the messaging system. Remember:
- observers can be any type
- messages can be any type
Naturally, we'll reach for trait objects since we know that they allow us to work with any type as long as it implements some trait.
Let's start with a simple implementation. Remember that generic type parameter M represents the concrete type that a particular observer can handle:
// Our trait from earlier.
pub trait Observer<M> {
fn handle(&mut self, msg: &M);
}
// First draft of a messaging system.
pub struct MessageSystem<M> {
// Using trait objects here since any observers can be added.
observers: Vec<Box<dyn Observer<M>>>,
}
// Make a default message system
impl<M> Default for MessageSystem<M> {
fn default() -> Self {
Self {
observers: Default::default(),
}
}
}
This compiles, but it doesn't work the way we want it to. Generic type parameter M represents a single message type. This is because generic types get evaluated at compile-time and are replaced with a concrete type. If we tried to use this version of the messaging system, we would do so like this:
let system = MessageSystem::default();
and then immediately get this error:
If we then specify a type as recommended by the compiler:
let system: MessageSystem<SayHello> = MessageSystem::default();
it becomes a little clearer that this message system will now only work with SayHello messages. So while we can add observers of any type to this message system, we cannot send messages of any type.
We want MessageSystem to work with any message type and any observer type, so maybe if we just take out the M we would be good to go:
But our Observer trait uses a generic M, so we cannot take M out of the MessageSystem because Observer needs to know what type to actually observe. But if we include M in the MessageSystem, then we are limited to a single message.
So it seems like we are running out of options. We could change the Observer trait by removing generic parameter M and then putting all the messages into an enum like this:
pub trait Observer {
fn handle(&mut self, msg: &Message);
}
pub enum Message {
SayHello(String),
SayGoodbye(String),
}
This would work because there is no more generic type parameter, but it introduces some problems:
- Eventually
Messageis going to have a ludicrous number of variants. - We need to match on the variant we care about in the every observer handler.
- It doesn't meet our initial requirement of "messages can be any type" (
typebeing the keyword here).
Type Erasing Another Trait
Recap
- Generic type parameter
Mmust be removed from theMessageSystemso it can process any message type - We cannot store
Observer<M>trait objects in theMessageSystembecause it needs generic type parameterM
The solution is conceptually simple, but fairly tricky to implement.
We know that we can't have generic type parameter M in the Observer trait object because that forces us to use M on the system itself, which limits the message types the system can send. So instead of using Observer as a trait object, we will create a new trait that doesn't have generic type parameters.
This new trait will have the same methods as the Observer trait and will exist only to forward/proxy method calls to the Observer. It will be implemented only for a new wrapper struct, which will forward method calls to the Observer. Since the new trait will have no generic type parameters, we can use the implementing wrapper struct as a trait object.
This will cause our message flow to go through the system like this:
message -> system -> wrapper -> observer
Let's create the trait. Remember, it needs to have the same methods as Observer so it can forward the method calls:
// Our trait from earlier
pub trait Observer<M> {
fn handle(&mut self, msg: &M);
}
// The new trait to erase the `M` on Observer
trait ErasedObserver {
// (We'll talk about `&self` instead of `&mut self` in the next section)
fn handle_any(&self, msg: &dyn Any);
}
Instead of generic type parameter M, we use Any from the standard library. This will allow us to send any message to our wrapper structure.
Additionally, the name chosen is handle_any so it's clear what method we are working with when we later start working with both traits.
Now the question arises:
If we can use any message with the
Anytype, why jump through all of these hoops?
While we can just use Any and be done, we lose the compile-time type checking. So not only will the type be erased at runtime, we also won't be able to use a concrete type in the code. Our observers would just get sent any messages and we would have to convert it in the observer code and hope for the best. This doesn't lead to maintainable code, so it's not an appealing option. Let's continue on!
Since our ErasedObserver trait has no generic type parameters, we can box it up for our system.
Proxy Structure
Now comes the tricky part. We're going to create the wrapper structure that bridges the gap between our fully-typed Observer trait that handles specific messages types, and our ErasedObserver trait that can handle any message type.
Let's start by putting the observer into the wrapper:
// Any `T` that implements observer can be placed within this struct.
struct ObserverWrapper<T>
where
T: Observer<M>,
{
// We use `Rc<RefCell<T>>` so all handlers for an observer
// point to the same instance. This is why we only need `&self`
// on the `ErasedObserver` trait.
observer: Rc<RefCell<T>>,
}
Whoops, we can't forget about type M for the Observer trait:
struct ObserverWrapper<T, M>
where
T: Observer<M>,
{
observer: Rc<RefCell<T>>,
}
But this generates an error:
Since generic type parameter M isn't used in the struct itself, we get an error. Thankfully the compiler hints at using PhantomData from the standard library.
PhantomData provides a way for us to "use" the generic type parameter within the struct. This is a compile-time only construct, and doesn't impact anything at runtime. We add a _phantom field (you can name it anything) to resolve the error:
struct ObserverWrapper<T, M>
where
T: Observer<M>,
{
observer: Rc<RefCell<T>>,
_phantom: PhantomData<M>,
}
// Let's implement a `new` method so we can wrap up an
// observer. We need to specify the same generic type
// parameters as we did on the struct.
impl<T, M> ObserverWrapper<T, M>
where
T: Observer<M>
{
// Our `T` is any observer.
pub fn new(observer: Rc<RefCell<T>>) -> Self {
// We store the observer inside our wrapper for later.
Self {
observer,
// Make the compiler happy :)
_phantom: PhantomData,
}
}
}
Implementing ErasedObserver
We need to implement ErasedObserver on our ObserverWrapper. This is the part that forwards the method calls to the Observer trait. When a message comes in we will try to change it to the type that the observer understands. We can do this because the ObserverWrapper has the type information provided from Observer via generic parameter M.
Here are the things we are working with:
// The trait we are implementing
trait ErasedObserver {
fn handle_any(&self, msg: &dyn Any);
}
// The wrapper struct we are implementing `ErasedObserver` on
struct ObserverWrapper<T, M>
where
T: Observer<M>,
{
observer: Rc<RefCell<T>>,
_phantom: PhantomData<M>,
}
and here is how we can forward the handle method:
// message -> system -> wrapper -> observer
// we are here ^^^^^^
//
impl<T, M> ErasedObserver for ObserverWrapper<T, M>
where
T: Observer<M>,
// This bound is required for messages. It's offered as a suggestion by the
// compiler if you forget to include it.
M: 'static,
{
// Message comes in as `Any`
fn handle_any(&self, msg: &dyn Any) {
// Try to downcast it to the known message type `M`
if let Some(msg) = msg.downcast_ref::<M>() {
// If it works, get mutable observer access
let mut observer = self.observer.borrow_mut();
// Then send the typed message to the `Observer::handle` method
observer.handle(msg);
}
}
}
I know that was a lot, but we are at the home stretch! Now we can finish up the MessageSystem.
Updating the Message System
Finally we can change the MessageSystem implementation so that it works with ErasedObserver and get rid of that generic M:
// This was our first draft
pub struct MessageSystem<M> {
observers: Vec<Box<dyn Observer<M>>>,
}
// Here is the updated version. No generics!
pub struct MessageSystem {
observers: Vec<Box<dyn ErasedObserver>>,
}
We should also provide a way to add new observers:
impl MessageSystem {
// Recall that our ObserverWrapper uses Rc+RefCell so
// we can have multiple handlers pointing to one observer.
pub fn add_observer<T, M>(&mut self, observer: Rc<RefCell<T>>)
where
T: Observer<M> + 'static,
M: 'static,
{
// Wrap up the observer
let observer = Box::new(ObserverWrapper::new(observer));
self.observers.push(observer);
}
}
While we are here, let's also add a method to send messages:
impl MessageSystem {
pub fn send<M>(&self, msg: M)
where
M: 'static,
{
for observer in &self.observers {
observer.handle_any(&msg);
}
}
}
We're done 🎉!
Review
Let's check our original API to see how close we were able to get. We initially planned to have something like this:
// Create a system
let system = MessageSystem::default();
// Create an observer that prints a greeting
let observer = GreetingObserver::default();
// Add the observer to the system
system.add_observer(observer);
// Create a message
let msg = SayHello("world".to_string());
// Send it
system.send(&msg);
// We would expect that the GreetingObserver prints "Hello, world!"
Our final usage looks like this:
// Create a system
let mut system = MessageSystem::default();
// Create an observer
let observer = Rc::new(RefCell::new(GreetingObserver::default()));
// Add the observer to the system.
// The message type must be specified so the wrapper
// knows which message it's handling.
system.add_observer::<_, SayHello>(observer.clone());
system.add_observer::<_, SayGoodbye>(observer.clone());
// Create and send messages
let msg = SayHello("world".to_string());
system.send(msg);
let msg = SayGoodbye("blog".to_string());
system.send(msg);
// Make sure it actually works
let msg_count = observer.borrow().greet_count;
assert_eq!(msg_count, 2);
While our final implementation doesn't exactly match the initial API, we came pretty close. We could hide the Rc+RefCell call with helper functions or with a new method on the system that automatically puts the observer behind Rc+RefCell. We could also write a macro that generates the add_observer calls for each type. So it's far from perfect. But it helped demonstrate how type erasure works!
Extra Credit - Sending Messages From Observers
The system can process any messages from any observers, but the only way to get messages into the system is by having access to the system itself. We need a way for observers to also send messages.
We will create a context struct called Ctx which will allow observers to send messages back into the system:
// A type alias will come in handy.
pub type AnyMsg = Box<dyn Any>;
// This will get sent to the observers.
#[derive(Default)]
pub struct Ctx {
msgs: Vec<AnyMsg>,
}
impl Ctx {
// Public method to queue a message for sending.
pub fn send<M>(&mut self, msg: M)
where
M: 'static,
{
self.msgs.push(Box::new(msg));
}
}
We'll need to change a few signatures:
pub trait Observer<M> {
// Added `ctx: &mut Ctx`
fn handle(&mut self, ctx: &mut Ctx, msg: &M);
}
trait ErasedObserver {
// Added `send_msgs: &mut VecDeque<AnyMsg>`
fn handle_any(&self, send_msgs: &mut VecDeque<AnyMsg>, msg: &dyn Any);
}
Which also means we need to update the observers:
impl Observer<SayHello> for GreetingObserver {
// Added `ctx: &mut Ctx`
fn handle(&mut self, ctx: &mut Ctx, hello: &SayHello) {
self.greet_count += 1;
println!("Hello, {}!", hello.0);
// Let's also send a message back out
ctx.send(SayGoodbye("blog".to_owned()));
}
}
impl Observer<SayGoodbye> for GreetingObserver {
// Added `ctx: &mut Ctx`
fn handle(&mut self, ctx: &mut Ctx, goodbye: &SayGoodbye) {
self.greet_count += 1;
println!("Goodbye, {}!", goodbye.0);
}
}
Now we need to make some changes to the send method in the MessageSystem. This will allow us to process messages until all observers have stopped sending:
pub fn send<M>(&self, msg: M)
where
M: 'static,
{
// We need to create a deque and add our initial message to it
let mut msgs: VecDeque<AnyMsg> = VecDeque::new();
msgs.push_back(Box::new(msg));
// Now continually pop messages until the deque is empty
while let Some(msg) = msgs.pop_front() {
for observer in &self.observers {
// We use `&*msg` here to go from `Box<dyn Any>` to `&dyn Any`
observer.handle_any(&mut msgs, &*msg);
}
}
}
The msgs deque gets passed to the ObserverWrapper, so we need to update it as well. This is what enables the observers to send mesages back to the system:
fn handle_any(&self, send_msgs: &mut VecDeque<AnyMsg>, msg: &dyn Any) {
if let Some(msg) = msg.downcast_ref::<M>() {
// Create a new context per call
let mut ctx = Ctx::default();
// Borrow just like before
let mut observer = self.observer.borrow_mut();
// Send in the context and the message
observer.handle(&mut ctx, msg);
// Move the pending messages from the observer context
// into the deque.
send_msgs.extend(ctx.msgs.into_iter());
}
}
With all of the above changes, we now have a usable messaging system that features:
- adding observers of any type
- sending messages of any type
- observers able to send messages
Notes For Expansion
The current implementation is purposefully simple, naïve, and minimal. If you wanted to use a system like this in one of your projects, here are some things to work on:
- The observers are in a plain
Vec. Message downcasts are attempted on all messages by all observers via theObserverWrapper, regardless of whether they registered a handler. Use something like aHashMapwithTypeIdto only send messages to the correct handlers:
pub struct MessageSystem {
observers: HashMap<TypeId, Vec<Box<dyn ErasedObserver>>>,
}
- There is no way to remove observers. Introduce
ObserverIdand pass it via theCtxso observers have access to their ID. They could then use this to remove themselves from the system. - There is no way to remove specific handlers. This would need another ID like
HandlerIdand another way to map handlers to observers. Ctxonly forwards messages. Changing this to use a command enum would give observers more ways to interact with the system:
pub enum SystemCommand {
SendMsg(AnyMsg),
UnsubscribeAll(ObserverId),
UnsubscribeHandler(HandlerId),
}
- Observers can spawn tasks/threads, but those tasks/threads cannot send messages back into the system. You could use a channel instead of a
VecDequefor holding messages to be sent, which will then allow you toClonetheCtxand then send it to another thread. Instead of processing the messages in thesendmethod loop, you can move the loop outside the system to receive messages from all sources:
let (tx, rx) = crossbeam_channel::unbounded();
// Pass tx into the system so it can create new senders for `Ctx`
let mut system = MessageSystem::new(tx);
// (add observers)
loop {
if let Ok(msg) = rx.recv() {
// You'd create a new method to send an already-boxed message
system.send_boxed(msg);
}
}
Conclusion
This post demonstrated how using type erasure with trait objects in Rust enables the creation of flexible, runtime-polymorphic systems. By abstracting concrete types behind traits objects, we can build extensible architectures where types can be added without modifying existing code.
The solution leveraged key Rust concepts:
- Trait objects (
Box<dyn ErasedObserver>) to store diverse types. PhantomDatato satisfy compiler requirements for unused generics while retaining type information at compile-time.Rc<RefCell<T>>to manage shared ownership and mutability of observers across handlers.
While this approach did introduce complexity, such as manual downcasting and some performance overhead via Boxes and Rc<RefCell<T>>, it provides the flexibility needed for dynamic systems.
Thanks for reading! I hope this post has helped to increase your understanding about type erasure and trait objects.
Feeling overwhelmed?
Establish strong fundamentals with my comprehensive video course which provides the clear, step-by-step guidance you need. Learn Rust starting from the basics up through intermediate concepts to help prepare you for articles on advanced Rust topics.
Enrollment grants you access to a large, vibrant Discord community for support, Q&A, and networking with fellow learners. 🚀 Unlock the full Rust learning experience, including our entire content library, when you enroll at ZeroToMastery.
Note
Full source code for this post is available at GitHub.