All articles
Beyond dyn Trait: Advanced Runtime Polymorphism and the Caster Pattern
Traits & Generics

Beyond dyn Trait: Advanced Runtime Polymorphism and the Caster Pattern

In Rust, we are evangelists of static analysis. We lean on monomorphization, forcing the compiler to generate specialized assembly for…

By Luis SoaresJanuary 22, 2026Original on Medium

In Rust, we are evangelists of static analysis. We lean on monomorphization, forcing the compiler to generate specialized assembly for every generic instantiation. We use enums when the set of types is closed, and we use standard trait objects (dyn Trait) when the behavior is shared but the types are open.

But there is a specific class of architectural problems — dependency injection containers, plugin systems, heterogeneous event buses, and Entity Component Systems (ECS) — where the type system’s rigidity becomes a blocker. You often find yourself needing to store something now and figure out what it actually is later.

This is where std::any::Any comes in. It is Rust's reflection capability in miniature: safe, runtime dynamic typing.

This article explores the mechanics of type erasure, the internal memory layout of trait objects, the limitations of upcasting, and advanced patterns like Cloneable Any and Trait Tagging.


1. The Internals: TypeId and the VTable

At its simplest, Any is a trait automatically implemented for any type that satisfies 'static.

Rust

pub trait Any: 'static {
    fn type_id(&self) -> TypeId;
}

The 'static Bound

The 'static constraint is not a suggestion; it is a soundness requirement. Any works by identifying types via a globally unique identifier. If Rust allowed you to cast a reference &'a str to dyn Any, the compiler would need to generate a unique TypeId that encodes the lifetime region 'a. Since lifetimes are erased during compilation, there is no way to distinguish Ref<'a> from Ref<'b> at runtime. Therefore, Any is strictly for owned data or data containing 'static references.

TypeId: The Runtime Token

The TypeId is a 128-bit hash generated by the compiler.

  • Uniqueness: It is statistically guaranteed to be unique for every distinct concrete type.
  • Instability: It is not stable across compiler versions. A TypeId generated in rustc 1.70 will likely differ from rustc 1.75. Never serialize TypeId to a database or send it over the wire; it is valid only for the lifespan of the current process binary.

Memory Layout

When you create a Box<dyn Any>, you are constructing a Fat Pointer.

  • pointer: Points to the heap-allocated data.
  • vptr: Points to the vtable for Any.

Crucially, the vtable for Any contains a function pointer to type_id(). When you attempt to downcast, the runtime invokes this function to retrieve the ID of the stored item and compares it against the TypeId of the target generic T.


2. Downcasting: ref, mut, and Box

Downcasting is the operation of asserting that a type-erased dyn Any is actually a specific concrete type T. Rust provides three flavors of downcasting.

The Immutable/Mutable Reference Downcast

If you have a reference to the trait object, you can try to get a reference to the concrete type. This does not take ownership.

use std::any::Any;

fn inspect(item: &dyn Any) {
    if let Some(string) = item.downcast_ref::<String>() {
        println!("It's a string: {}", string);
    } else if let Some(num) = item.downcast_ref::<i32>() {
        println!("It's a number: {}", num);
    } else {
        println!("Unknown type {:?}", item.type_id());
    }
}

The Owned Downcast (Box<dyn Any>)

This is where things get interesting. Box::downcast consumes the box. If the cast succeeds, you get Ok(Box<T>). If it fails, you get Err(Box<dyn Any>), handing ownership of the original type-erased object back to you.

This is vital for processing chains where you might want to try several types in sequence and pass the failures along.

fn unwrap_string(item: Box<dyn Any>) -> String {
    match item.downcast::<String>() {
        Ok(boxed_string) => *boxed_string,
        Err(_) => String::from("Not a string!"),
    }
}

3. The Upcasting Friction

Coming from languages like C# or Java, one expects subtyping to be implicit. If MyService implements Service, and Service extends Any, you expect to be able to cast Box<dyn Service> to Box<dyn Any>.

In Rust, this is a compile-time error.

trait Service: Any {}

struct Logger;

impl Service for Logger {}

let s: Box<dyn Service> = Box::new(Logger);
// let a: Box<dyn Any> = s; // ERROR: "Mismatched types"

The Technical Reason: A trait object is a wrapper around a vtable. The vtable for dyn Service contains pointers to Service methods. It does not contain the vtable entries required to reconstruct a dyn Any fat pointer. While the compiler technically knows that the underlying type implements Any, it cannot construct the new vtable pointer at runtime without help.

The Solution: The as_any Pattern

To bridge this gap, you must expose a helper method in your trait that performs the pointer cast while the concrete type is known (inside the impl block).

trait Service: Any {
    fn run(&self);
    fn as_any(&self) -> &dyn Any;
}

impl Service for Logger {
    fn run(&self) { println!("Logging..."); }
    fn as_any(&self) -> &dyn Any { self }
}

fn main() {
    let s: Box<dyn Service> = Box::new(Logger);

    // Works: dyn Service -> dyn Any -> Concrete
    if let Some(logger) = s.as_any().downcast_ref::<Logger>() {
        logger.run();
    }
}

4. Pattern: The CloneAny Problem

Practice what you learned

Reinforce this article with hands-on coding exercises and AI-powered feedback.

View all exercises

Another common frustration is that Box<dyn Any> does not implement Clone. This makes sense—how do you clone a type when you don't know how big it is or how to copy its fields?

However, if you are building a TypeMap or a prototype registry, you often need this capability. We solve this using a similar vtable-hopping trick to the as_any pattern.

use std::any::Any;

// 1. Define a trait that merges Any with the ability to clone itself
trait CloneAny: Any {
    fn clone_box(&self) -> Box<dyn CloneAny>;
    fn as_any(&self) -> &dyn Any;
}

// 2. Blanket implementation for any type that is Any + Clone
impl<T: Any + Clone> CloneAny for T {
    fn clone_box(&self) -> Box<dyn CloneAny> {
        Box::new(self.clone())
    }

    fn as_any(&self) -> &dyn Any {
        self
    }
}

// 3. Implement Clone for the Boxed trait object manually
impl Clone for Box<dyn CloneAny> {
    fn clone(&self) -> Box<dyn CloneAny> {
        self.clone_box()
    }
}

fn main() {
    let a: Box<dyn CloneAny> = Box::new(String::from("Hello"));
    let b = a.clone(); // "Magic" cloning of an erased type

    let b_str = b.as_any().downcast_ref::<String>().unwrap();
    println!("Cloned value: {}", b_str);
}

5. Architecture: The Tagging / “Caster” Pattern

The standard usage of Any (like a HashMap<TypeId, Box<dyn Any>>) has a major flaw: Indexability.

If you have a collection of erased types, you cannot iterate over them and treat them as a shared interface. For example, you cannot ask a standard TypeMap: “Give me all components that implement Renderer."

To solve this, we use the Caster Pattern. We store a secondary index of closures. These closures serve as “bridges,” knowing how to cast the dyn Any to a specific dyn Trait.

The Implementation

We will build a ServiceContainer that allows types to be registered, and then queried either by their concrete type or by a shared trait tag.

use std::any::{Any, TypeId};
use std::collections::HashMap;

// The capability we want to query dynamically
trait Startable {
    fn start(&self);
}

/// A "Caster" is a function that knows how to turn &dyn Any into &dyn Startable.
/// It returns Option because the Any might not actually match the expected type (safety).
type StartableCaster = Box<dyn Fn(&dyn Any) -> Option<&dyn Startable>>;
struct ServiceContainer {
    // Primary storage: TypeId -> Concrete Data
    items: HashMap<TypeId, Box<dyn Any>>,

    // Secondary index: TypeId -> Bridge Function
    casters: HashMap<TypeId, StartableCaster>,
}

impl ServiceContainer {
    fn new() -> Self {
        Self { items: HashMap::new(), casters: HashMap::new() }
    }

    /// Register a simple type (not startable)
    fn register<T: Any>(&mut self, item: T) {
        self.items.insert(TypeId::of::<T>(), Box::new(item));
    }

    /// Register a type that is ALSO Startable
    fn register_startable<T: Any + Startable>(&mut self, item: T) {
        let type_id = TypeId::of::<T>();

        // 1. Store the data normally
        self.items.insert(type_id, Box::new(item));

        // 2. Create the bridge
        // This closure captures the generic type T, effectively "remembering" it for later.
        let caster = Box::new(|any: &dyn Any| {
            // Downcast Any -> Concrete T -> Coerce to dyn Startable
            any.downcast_ref::<T>().map(|t| t as &dyn Startable)
        });

        self.casters.insert(type_id, caster);
    }

    /// Retrieve by Concrete Type
    fn get<T: Any>(&self) -> Option<&T> {
        self.items.get(&TypeId::of::<T>())?.downcast_ref::<T>()
    }

    /// Iterate over everything that is "Startable"
    fn start_all(&self) {
        for (type_id, caster) in &self.casters {
            if let Some(item) = self.items.get(type_id) {
                // Use the bridge to get the trait object
                if let Some(startable) = caster(item.as_ref()) {
                    startable.start();
                }
            }
        }
    }
}

// --- Usage ---
struct Database;

impl Startable for Database { fn start(&self) { println!("DB Started"); } }

struct Cache; // Not startable

struct HttpServer;

impl Startable for HttpServer { fn start(&self) { println!("HTTP Started"); } }

fn main() {
    let mut container = ServiceContainer::new();

    container.register_startable(Database);
    container.register(Cache);
    container.register_startable(HttpServer);

    // 1. Concrete access still works
    assert!(container.get::<Cache>().is_some());

    // 2. Trait-based batch processing
    println!("--- Booting System ---");
    container.start_all();
}

Why this is powerful: This mimics the behavior of ECS (Entity Component Systems) “Family” or “Archetype” lookups. We have decoupled the storage of the data (which is type-erased) from the capability of the data (which is reconstructed via the caster).


6. Performance vs. Enums

Before rushing to use Any everywhere, consider the performance profile.

  • Enums: If your types are known at compile time, use an enum. Dispatching on an enum is a simple integer comparison (tag check) and a jump. It is extremely cache-friendly and allows the compiler to inline code in the match arms.
  • dyn Trait: Standard dynamic dispatch involves one pointer dereference (to the vtable) and an indirect function call.
  • dyn Any: Downcasting involves a function call (type_id), a 128-bit integer comparison, and then the pointer cast. It is slower than an enum but generally comparable to standard vtable overhead.

The real cost of Any is not CPU cycles, but optimization barriers. Because the type is erased, the compiler cannot inline functions through the downcast boundary easily.

Summary

std::any::Any is the tool of choice when the open-endedness of your system outweighs the need for static guarantees.

  • Use Box<dyn Any> for heterogeneous storage.
  • Use TypeId as a HashMap key to implement Dependency Injection or generic caches.
  • Use the as_any trait method to solve upcasting limitations.
  • Use the Caster Pattern to implement “Interface Querying” on erased types.

By mastering these patterns, you can build Rust architectures that rival dynamic languages in flexibility while retaining the safety and performance of systems programming.

Practice what you learned

Reinforce this article with hands-on coding exercises and AI-powered feedback.

View all exercises

Want to practice Rust hands-on?

Go beyond reading — solve interactive exercises with AI-powered code review on Rust Lab.