Programs often use abstractions to treat objects of different types uniformly. Languages call these mechanisms by different names—traits in Rust, interfaces in other languages—but the idea is the same: an abstraction defines a set of behaviors (methods) that implementing types must provide. When code depends only on those behaviors, it doesn’t need to know an object’s concrete type; it can operate the same way whether it encounters the same type repeatedly or many different types at runtime.
Problem
Rust, like many other languages, lets you put values into collections—Vec
is the most common example. More importantly for this post, Rust lets you build collections of trait objects (values that implement a particular trait) and iterate over them. The example below demonstrates this pattern:
#[derive(Debug)]
#[allow(dead_code)]
struct Coords {
#[allow(unused_must_use)]
pub x: f32,
#[allow(unused_must_use)]
pub y: f32,
}
#[derive(Debug)]
struct Pedestrian {
pub name: String,
pub location: Coords,
}
#[derive(Debug)]
struct Cyclist {
pub name: String,
pub location: Coords,
}
#[derive(Debug)]
struct Motorist {
pub name: String,
pub location: Coords,
}
trait Route {
fn route(&self, destination: &Coords);
}
impl Route for Pedestrian {
fn route(&self, destination: &Coords) {
println!("{} walks from {:?} to {destination:?}", self.name, self.location);
}
}
impl Route for Cyclist {
fn route(&self, destination: &Coords) {
println!("{} cycles from {:?} to {destination:?}", self.name, self.location);
}
}
impl Route for Motorist {
fn route(&self, destination: &Coords) {
println!("{} drives from {:?} to {destination:?}", self.name, self.location);
}
}
fn main() {
let pedestrian = Pedestrian {name: "Jane".into(), location: Coords {x: 0.0, y: 1.0}};
let cyclist = Cyclist {name: "Jon".into(), location: Coords {x: 2.0, y: 3.0}};
let motorist = Motorist {name: "Nick".into(), location: Coords {x: 4.0, y: 5.0}};
let destination = Coords { x: 30.2, y: 12.0 };
let friends: Vec<&dyn Route> = vec![&pedestrian, &cyclist, &motorist];
for friend in friends {
friend.route(&destination);
}
}
This example defines a Route
trait with a single method, route
, and implements it for three types: Pedestrian
, Cyclist
, and Motorist
. Each implementation models travel behavior appropriate to that type (e.g., pedestrians don’t use motorways; cars don’t enter pedestrian-only streets).
In main
we create one instance of each type and collect references to them in a Vec<&dyn Route>
. By storing trait objects in a vector we can treat heterogeneous values uniformly: iterate over them and call route
without knowing each element’s concrete type. Method calls are dispatched at runtime, so each element executes the implementation that matches its actual type.
This pattern demonstrates runtime polymorphism with trait objects. Here is the output of running the program:
$ cargo run
Compiling trait_serialization v0.1.0 (/home/ikanyuka/projects/trait_serialization)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.50s
Running `target/debug/trait_serialization`
Jane walks from Coords { x: 0.0, y: 1.0 } to Coords { x: 30.2, y: 12.0 }
Jon cycles from Coords { x: 2.0, y: 3.0 } to Coords { x: 30.2, y: 12.0 }
Nick drives from Coords { x: 4.0, y: 5.0 } to Coords { x: 30.2, y: 12.0 }
Now let’s serialize and persist these values. To do that, we’ll use the serde
and serde_json
crates. Import them and derive Serialize and Deserialize (we’ll need Deserialize later) for the types, for example:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
#[allow(dead_code)]
struct Coords {
#[allow(unused_must_use)]
pub x: f32,
#[allow(unused_must_use)]
pub y: f32,
}
#[derive(Serialize, Deserialize, Debug)]
struct Pedestrian {
pub name: String,
pub location: Coords,
}
...
Next, add code to serialize a single instance and print the JSON at the end of main
:
let pedestrian_serialized = serde_json::to_string(&pedestrian).unwrap();
println!("Serialized pedestrian {pedestrian_serialized}");
This works as expected:
$ cargo run
Compiling trait_serialization v0.1.0 (/home/ikanyuka/projects/trait_serialization)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.79s
Running `target/debug/trait_serialization`
Jane walks from Coords { x: 0.0, y: 1.0 } to Coords { x: 30.2, y: 12.0 }
Jon cycles from Coords { x: 2.0, y: 3.0 } to Coords { x: 30.2, y: 12.0 }
Nick drives from Coords { x: 4.0, y: 5.0 } to Coords { x: 30.2, y: 12.0 }
Serialized pedestrian {"name":"Jane","location":{"x":0.0,"y":1.0}}
But here’s the catch: we serialized a single concrete value, not the entire collection of trait objects. Let’s try to serialize the vector of friends by adding these lines at the end of main:
let friends_serialized = serde_json::to_string(&friends).unwrap();
println!("Serialized friends {friends_serialized}");
At first glance this should work — all of the concrete types and their fields derive Serialize. Instead, the compiler emits:
error[E0277]: the trait bound `dyn Route: serde::Serialize` is not satisfied
--> src/main.rs:68:52
|
68 | let friends_serialized = serde_json::to_string(&friends).unwrap();
| --------------------- ^^^^^^^^ the trait `Serialize` is not implemented for `dyn Route`
| |
| required by a bound introduced by this call
So although each concrete type is serializable, the trait object dyn Route
itself does not implement serde::Serialize
. In other words, serializing a Vec<&dyn Route>
fails. We need a way to serialize trait objects (and thus the whole collection) — the solution comes next.
Solution
Use the typetag crate. typetag
integrates with Serde and provides attribute macros that make trait objects (de)serializable: it supports serialization of &dyn Trait
and serialization + deserialization of Box<dyn Trait>
. The macros generate a Serde-like enum representation for the trait, so each implementation of the trait is treated like an enum variant when (de)serializing. Docs.rs
How it works, briefly: you annotate the trait with #[typetag::serde(...)]
(you can choose the enum-style representation — externally, internally, or adjacently tagged by using tag
and/or content
), and annotate each impl
with #[typetag::serde]
. During serialization typetag
emits the chosen Serde enum representation (including the tag that identifies the concrete variant — the tag defaults to the type name but can be overridden per impl). On deserialization typetag
uses a registry of impls (built via inventory) to map the tag back to the correct deserializer and reconstruct the concrete value (into a Box<dyn Trait>
). The crate also uses erased-serde to preserve object safety, and it works across crates and compact formats such as bincode.
You can learn more about Serde’s enum representations in the official documentation.
So, to make our example working we need to do:
- Annotate the trait with
#[typetag::serde(...)]
, specifying the tag field name to store type information (e.g.tag = "type"
). In this example we will use internal tagging. - Annotate each
impl
of that trait with#[typetag::serde]
sotypetag
can generate the (de)serialization glue for that implementation. - Make sure each concrete type still derives
Serialize
andDeserialize
(we already added those earlier). - When deserializing, use owned trait objects (e.g.
Box<dyn Route>
) because the deserializer must create owned values rather than references.
In our example the trait and one impl look like this:
#[typetag::serde(tag = "type")]
trait Route {
fn route(&self, destination: &Coords);
}
#[typetag::serde]
impl Route for Pedestrian {
fn route(&self, destination: &Coords) {
println!("{} walks from {:?} to {destination:?}", self.name, self.location);
}
}
After adding those attributes the program serializes the entire vector and includes the concrete type names in the designated field:
$ cargo run
Compiling trait_serialization v0.1.0 (/home/ikanyuka/projects/trait_serialization)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.22s
Running `target/debug/trait_serialization`
Jane walks from Coords { x: 0.0, y: 1.0 } to Coords { x: 30.2, y: 12.0 }
Jon cycles from Coords { x: 2.0, y: 3.0 } to Coords { x: 30.2, y: 12.0 }
Nick drives from Coords { x: 4.0, y: 5.0 } to Coords { x: 30.2, y: 12.0 }
Serialized pedestrian {"name":"Jane","location":{"x":0.0,"y":1.0}}
Serialized friends [{"type":"Pedestrian","name":"Jane","location":{"x":0.0,"y":1.0}},{"type":"Cyclist","name":"Jon","location":{"x":2.0,"y":3.0}},{"type":"Motorist","name":"Nick","location":{"x":4.0,"y":5.0}}]
To deserialize we switch to an owned vector of boxed trait objects and call the methods on the reconstructed values:
let friends_copy: Vec<Box<dyn Route>> = serde_json::from_str(&friends_serialized).unwrap();
for friend_copy in friends_copy {
friend_copy.route(&destination);
}
Notice the change from Vec<&dyn Route>
to Vec<Box<dyn Route>>
: the original vector held references to existing stack values, but deserialization must produce owned heap values so we use Box<dyn Route>
.
Running the program now shows the deserialized objects behaving exactly like the originals:
$ cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.06s
Running `target/debug/trait_serialization`
Jane walks from Coords { x: 0.0, y: 1.0 } to Coords { x: 30.2, y: 12.0 }
Jon cycles from Coords { x: 2.0, y: 3.0 } to Coords { x: 30.2, y: 12.0 }
Nick drives from Coords { x: 4.0, y: 5.0 } to Coords { x: 30.2, y: 12.0 }
Serialized pedestrian {"name":"Jane","location":{"x":0.0,"y":1.0}}
Serialized friends [{"type":"Pedestrian","name":"Jane","location":{"x":0.0,"y":1.0}},{"type":"Cyclist","name":"Jon","location":{"x":2.0,"y":3.0}},{"type":"Motorist","name":"Nick","location":{"x":4.0,"y":5.0}}]
Jane walks from Coords { x: 0.0, y: 1.0 } to Coords { x: 30.2, y: 12.0 }
Jon cycles from Coords { x: 2.0, y: 3.0 } to Coords { x: 30.2, y: 12.0 }
Nick drives from Coords { x: 4.0, y: 5.0 } to Coords { x: 30.2, y: 12.0 }
That’s it — typetag
preserves concrete types across (de)serialization and lets you round-trip collections of trait objects cleanly.
Conclusion
Working with collections of trait objects in Rust makes your code more flexible, expressive, and reusable. You can write logic that operates on multiple types uniformly, without losing the benefits of Rust’s strong type system. The typetag
crate complements this by enabling seamless serialization and deserialization of such collections, preserving each element’s concrete type automatically. This makes it practical to persist polymorphic data structures, exchange them between systems, or store them for later use.
The [complete source code is available on GitHub(https://github.com/ifel/trait_serialization/blob/master/src/main.rs). Each stage of the implementation is captured in a separate commit, making it easy to follow the evolution from simple trait collections to fully serializable trait objects.
This project was developed in my HomeLab, which provides a safe and flexible environment to experiment with Rust, Serde, and trait-based designs. If you’re curious about setting up your own lab or exploring similar experiments, check out my HomeLab series. The latest post, Homelab: What to Run, even discusses scenarios like this one and how to test them in practice.
By combining trait collections with typetag
, you can manage dynamic, heterogeneous data in Rust without sacrificing type safety, maintainability, or runtime flexibility — a pattern that is both powerful and practical for real-world applications.