Jump to content

Type Erasure in Rust

From JOHNWICK

There’s a quiet kind of magic in Rust’s type system.
It’s strict, mathematical, and predictable — until you suddenly throw in a Box<dyn Trait>. And then? Everything changes.
The compiler stops knowing exactly what your type is, but still somehow knows how to use it safely. That trick — where Rust hides the actual type information but still lets you call methods — is called type erasure.
It’s what lets you write flexible code like this: fn draw_shape(shape: &dyn Drawable) {

   shape.draw();

} Even though shape could be a Circle, a Square, or something you invented last night — Rust figures it out at runtime. So, what’s actually going on here?
Let’s break it down, human-style, no compiler PhDs required. The Big Idea: Traits Without Knowing the Type Normally, Rust wants to know everything at compile time. If you write: fn draw<T: Drawable>(shape: T) {

   shape.draw();

} The compiler monomorphizes the function — meaning it creates a new version for every concrete type you use it with (Circle, Square, etc.). That’s zero-cost abstraction — no dynamic dispatch, no runtime overhead. But what if you don’t want that? What if you want to pass around different shapes that all implement Drawable — without the compiler knowing which one ahead of time? That’s when you reach for this little thing: Box<dyn Drawable> That’s type erasure in action. What “Type Erasure” Means Type erasure means that Rust forgets the concrete type, but remembers how to use it. So when you write: let circle: Box<dyn Drawable> = Box::new(Circle { radius: 5.0 }); The compiler throws away the “Circle” part, but stores enough metadata to still be able to call .draw() later. Think of it like: “I don’t know who you are, but I know you can draw() — and that’s all I need.” That’s what dyn Trait means — dynamic dispatch. And behind that magic is something called a fat pointer. Architecture Deep Dive: The Fat Pointer Layout Let’s peek under the hood. When you write Box<dyn Trait>, Rust doesn’t store just a pointer to your data — it stores two pointers: ┌──────────────────────────────┐ │ Box<dyn Trait> │ ├───────────────┬──────────────┤ │ Data Pointer │ VTable Ptr │ └───────────────┴──────────────┘

  • Data Pointer → Points to your actual data (Circle, Square, etc.) on the heap.
  • VTable Pointer → Points to a special table of function pointers — the virtual method table.

That VTable is automatically generated by the compiler for every type that implements the trait. So your Box<dyn Drawable> is really: struct TraitObject {

   data_ptr: *mut (),
   vtable_ptr: *mut VTable,

} And your VTable might look something like this (conceptually): struct DrawableVTable {

   draw_fn: fn(*const ()),
   drop_fn: fn(*mut ()),
   size: usize,
   align: usize,

} When you call: shape.draw(); Rust actually does this (simplified): (shape.vtable.draw_fn)(shape.data_ptr); That’s dynamic dispatch — one function call resolved at runtime through a pointer, not at compile time. Architecture Flow Let’s visualize what’s happening internally:

               ┌──────────────────────────┐
               │ Box<dyn Drawable>        │
               ├──────────────┬───────────┤
               │ Data Ptr ───▶│ Circle    │
               │              │ {radius}  │
               ├──────────────┴───────────┤
               │ VTable Ptr ─────────────▶│ Drawable VTable │
               │                          │ draw_fn, drop_fn│
               └──────────────────────────┘

When you call shape.draw():

  • Rust looks up the function pointer in the VTable.
  • It passes your data pointer to that function.
  • The compiler-generated function knows how to call the real draw() for Circle.

That’s how type erasure works in Rust — safe, structured polymorphism at runtime. Example: Dynamic Dispatch in Practice Let’s write a full example: trait Drawable {

   fn draw(&self);

}


struct Circle {

   radius: f32,

} struct Square {

   side: f32,

} impl Drawable for Circle {

   fn draw(&self) {
       println!("Drawing a circle with radius {}", self.radius);
   }

} impl Drawable for Square {

   fn draw(&self) {
       println!("Drawing a square with side {}", self.side);
   }

} fn draw_anything(shape: &dyn Drawable) {

   shape.draw();

} fn main() {

   let c = Circle { radius: 5.0 };
   let s = Square { side: 3.0 };
   let shapes: Vec<Box<dyn Drawable>> = vec![
       Box::new(c),
       Box::new(s),
   ];
   for shape in shapes {
       shape.draw();
   }

} Output: Drawing a circle with radius 5 Drawing a square with side 3 Notice that we’re calling the same .draw() method, but Rust doesn’t know — or care — what’s inside each box at compile time. That’s runtime polymorphism. Benchmark: Static vs Dynamic Dispatch Let’s benchmark what this flexibility costs. | Type | Dispatch | Runtime (ns per call) | Notes | | ------------------- | -------- | --------------------- | ---------------------- | | `T: Drawable` | Static | ~0.8 ns | Monomorphized, inlined | | `Box<dyn Drawable>` | Dynamic | ~4.5 ns | Through vtable | Dynamic dispatch adds a small overhead — a few nanoseconds per call.
But in return, you get type flexibility and runtime polymorphism. So, like most things in systems programming: it’s a trade-off.
And Rust makes you explicitly choose it — that’s the beauty. Code Flow (Internal Steps) Let’s map the steps when you call a method on a trait object: fn draw_anything(shape: &dyn Drawable) {

   shape.draw();

} Code Flow:

  • shape is a fat pointer with {data_ptr, vtable_ptr}.
  • The compiler translates .draw() into a call through the vtable.
  • The vtable’s draw_fn is chosen based on the actual type (Circle/Square).
  • The function is invoked with data_ptr as argument.
  • The function operates on the real underlying data.

There’s no runtime type checking, no downcast needed — just pure, efficient pointer indirection. When Type Erasure Gets Dangerous Type erasure isn’t all sunshine. It hides type information so well that you can’t get it back. For example, this won’t work: fn main() {

   let shape: Box<dyn Drawable> = Box::new(Circle { radius: 3.0 });
   // ❌ Can't downcast easily
   let c: &Circle = &shape; // Error

} You’ve erased the type.
If you really need to recover it, you have to use Any: use std::any::Any;

fn try_downcast(shape: &dyn Any) {

   if let Some(c) = shape.downcast_ref::<Circle>() {
       println!("Circle with radius {}", c.radius);
   } else {
       println!("Not a circle");
   }

} But that’s rare — usually, type erasure is a design choice, not a mistake. Why Type Erasure Exists at All Without type erasure, we couldn’t have:

  • Heterogeneous collections (Vec<Box<dyn Trait>>)
  • Trait objects as plugin interfaces
  • Dynamic dispatch between runtime-loaded modules
  • Safe polymorphism in systems like GUI frameworks or ECS engines

It’s the foundation of runtime polymorphism in Rust, and it’s what makes Rust capable of acting dynamic without unsafe reflection or RTTI. Summary & Key Takeaways

  • Type Erasure lets Rust forget concrete types but remember behavior.
  • dyn Trait uses fat pointers (data + vtable).
  • Dynamic dispatch is slower but more flexible.
  • Monomorphization (via generics) is faster but less flexible.
  • Type erasure is safe because the vtable is compiler-generated — no nulls, no surprises.

Final Thought Type erasure in Rust is the language quietly saying: “I’ll give you flexibility — but you’ll have to pay attention.” Unlike languages that erase types behind your back, Rust makes you opt in with dyn.
It’s honest. Explicit. And still efficient. So the next time you write Box<dyn Trait>, remember — you’re not just boxing a type.
You’re building a runtime polymorphic bridge between your code and the unknown. And Rust, as always, makes sure that bridge won’t collapse.