All articles
The Architecture of Elasticity: Escaping “Generic Soup” with Type Erasure
Traits & Generics

The Architecture of Elasticity: Escaping “Generic Soup” with Type Erasure

In the Rust ecosystem, “Zero-Cost Abstractions” is the mantra. We are culturally conditioned to view Generics (<T: Trait>) as the only…

By Luis SoaresDecember 27, 2025Original on Medium

In the Rust ecosystem, “Zero-Cost Abstractions” is the mantra. We are culturally conditioned to view Generics (<T: Trait>) as the only "correct" way to write high-performance code. We prize monomorphization for its ability to turn abstract interfaces into specialized machine code, inlining function calls and stripping away overhead until the abstraction effectively disappears.

But in large-scale system design, raw execution speed is rarely the only metric that matters. As codebases grow from small scripts into multi-crate architectures, a dogmatic adherence to generics often leads to a subtle but debilitating architectural disease known as Generic Soup.

While generics optimize for runtime instruction count, they often sacrifice API Elasticity, Binary Size, and Compilation Velocity. To build systems that can evolve over time without breaking every downstream consumer, we must master the alternative: Type Erasure using dyn.


The Monomorphization Trap

To understand why we need Type Erasure, we first must understand exactly what we are opting into when we use Generics.

Rust uses a compilation model called Monomorphization (fancy Greek for “Single Form”). When you define a generic function, the compiler doesn’t actually compile it. It merely “checks” it. The actual compilation happens later, when you call the function.

Rust

fn process<T: Data>(item: T) { ... }

If you call process(String) and process(u32) in your code, the compiler copy-pastes the entire function body twice. It replaces T with String in the first copy and u32 in the second, then optimizes them independently.

The Hidden Cost: Binary Bloat and Cache Pressure

In small programs, this is fine. But in complex systems — like a web server using heavy middleware chains — this leads to exponential code growth.

If you have a generic struct Client<T, P, L> and you instantiate it with 3 transports, 2 protocols, and 2 loggers, the compiler generates $3 \times 2 \times 2 = 12$ distinct versions of your client logic.

This isn’t just about disk space. It affects CPU Instruction Cache (I-Cache) performance.

  • Generics: Your binary is huge. The CPU constantly has to fetch new “pages” of instructions from RAM because the generic variations don’t fit in the L1/L2 cache.
  • Type Erasure: You have one version of the code. It stays hot in the cache.

Ironically, for large enough systems, “slow” dynamic dispatch can sometimes be faster than generics because it reduces I-Cache misses.


The “Generic Soup” Phenomenon

The architectural cost of generics is often higher than the performance cost. Generics in Rust are Viral.

If a struct holds a generic field, the struct itself must be generic. This forces the type parameter to bubble up through every layer of your application hierarchy, polluting function signatures and struct definitions.

Consider a networking client designed to be flexible:

pub struct Client<T: Transport, P: Protocol> {
    transport: T,
    protocol: P,
}

This looks clean in lib.rs. But software does not exist in a vacuum. This Client will be embedded in a ConnectionPool, which is used by a RequestHandler, which is initialized in main. Suddenly, your function signatures—and the signatures of every function calling them—look like this:

// The "Generic Soup" Signature
fn handle_request<T, P, L, A>(
    ctx: Context,
    client: &Client<T, P>,
    logger: L,
    auth: A
) -> Result<(), Error>
where
    T: Transport + Clone + Debug + Send + Sync + 'static,
    P: Protocol + Serialize + DeserializeOwned,
    L: Logger + Send,
    A: Authenticator
{ ... }

The Consequences of Soup

  1. Refactoring Paralysis: If you want to add a Metrics trait to the Client, you have to update the type signature of Client, ConnectionPool, RequestHandler, and handle_request. A 5-minute change becomes a 5-hour refactor spanning 20 files.
  2. Unreadable Errors: Generic trait bound errors are notoriously difficult to decipher, often spanning multiple screen heights.
  3. API Rigidity: This is the killer. The types T and P are now exposed in the public API. Client<Tcp, Json> is a different type from Client<Quic, Json>. You cannot store them in the same Vec. You cannot swap them at runtime.

The Mechanics of Type Erasure (dyn)

Type Erasure is the process of removing the concrete type information at compile time and deferring the resolution to runtime. We achieve this using the dyn keyword and Trait Objects.

When you write Box<dyn Transport>, you are telling the compiler: "I don't care what this is, I only care that it implements Transport."

The “Fat Pointer” Anatomy

In C++, dynamic dispatch relies on objects having a hidden pointer to a vtable inside the object itself. Rust does it differently. Rust objects are just data. They don’t know about vtables.

When you cast a reference &MyStruct to a trait object &dyn MyTrait, Rust constructs a Fat Pointer. A standard pointer is 64 bits (8 bytes). A Fat Pointer is 128 bits (16 bytes).

  1. data_ptr (64 bits): Points to the actual MyStruct data in memory.
  2. vtable_ptr (64 bits): Points to a vtable (Virtual Method Table) specifically generated for the combination of MyStruct and MyTrait.

The vtable contains:

  • The size and alignment of MyStruct.
  • A pointer to the destructor (drop).
  • Function pointers for every method in MyTrait.

This means dyn is "pay-as-you-go." If you don't use it, your objects don't carry the overhead of a vtable pointer. If you do use it, the pointer lives on the reference, not the object.


The Elastic API Pattern

Practice what you learned

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

View all exercises

To cure “Generic Soup,” we use Type Erasure to decouple the Interface from the Implementation.

Let’s refactor our rigid Client using the Elastic API Pattern.

pub struct Client {
    // The specific types are erased.
    // The struct size is now fixed (two fat pointers).
    transport: Box<dyn Transport>,
    protocol: Box<dyn Protocol>,
}

impl Client {
    // The constructor takes generic types, but erases them immediately.
    // This acts as a "firewall" stopping the generics from spreading.
    pub fn new<T, P>(t: T, p: P) -> Self
    where
        T: Transport + 'static,
        P: Protocol + 'static
    {
        Self {
            transport: Box::new(t),
            protocol: Box::new(p)
        }
    }
}

Why this is superior for Libraries:

  1. Stable Types: Client is now just Client. It has no generic parameters.
  2. Hot Swapping: You can change the transport from TCP to QUIC at runtime based on a config file, and the Client struct doesn't change.
  3. Heterogeneous Collections: You can now have a Vec<Client> where one client talks to a legacy HTTP server and another talks to a modern gRPC server. This is mathematically impossible with the generic Client<T, P>.

The Delegating Wrapper Pattern

A common fear is that using Box<dyn Trait> makes the code awkward to use. You have to handle boxes, dereferencing, and lifetime issues.

The pro-move here is the Delegating Wrapper Pattern. You create a struct that looks like a normal struct but holds a trait object internally, and implements the trait itself by forwarding calls.

// The Trait
trait Logger {
    fn log(&self, msg: &str);
}

// The Elastic Wrapper
pub struct AppLogger {
    inner: Box<dyn Logger + Send + Sync>,
}

impl AppLogger {
    pub fn new<L: Logger + Send + Sync + 'static>(logger: L) -> Self {
        Self { inner: Box::new(logger) }
    }
}

// The "Delegation"
impl Logger for AppLogger {
    fn log(&self, msg: &str) {
        // Forward the call to the vtable
        self.inner.log(msg);
    }
}

Now, the rest of your application just uses AppLogger. They don't know it's dynamic. They don't see Box. They just call .log(). You have successfully encapsulated the dynamic dispatch.


Real-World Case Studies

The most mature crates in the Rust ecosystem typically follow a pattern: they use Generics for internal hot loops and Type Erasure for external API boundaries.

Case Study 1: anyhow vs std::error::Error

Error handling is the ultimate test case. A function performing File I/O, JSON parsing, and HTTP requests can fail in three ways (io::Error, serde::Error, reqwest::Error).

The Bad Way (Generic Soup):

fn do_work() -> Result<(), MyGiantEnum> { ... }

You end up maintaining a giant enum that wraps every error in the universe.

The anyhow Way (Erasure):

anyhow::Error is essentially a wrapper around Box<dyn std::error::Error + Send + Sync>.

It erases the specific failure details, preserving only the ability to display the error. This allows functions to return any error without changing their signature.

Case Study 2: tower and axum

The tower crate (which powers axum) relies heavily on the Service trait. In this architecture, Middleware wraps a Service.

  • Start with MyHandler
  • Wrap in TimeoutLayer -> Type is Timeout<MyHandler>
  • Wrap in CompressionLayer -> Type is Compression<Timeout<MyHandler>>

If you try to return this type from a function, good luck typing it out. If you try to put two routes in a list, you can’t, because they are different types.

tower solves this with BoxService. It’s a Type Erasure wrapper that delegates calls to the inner service. This allows axum to have a router that holds a map of routes, where every route has a completely different stack of middleware, but they all look like BoxService to the compiler.


When NOT to Erase

Type erasure is a trade-off. You should stick to Generics if:

  1. The trait is not “Object Safe”: Rust has strict rules. You cannot make a dyn Trait if the trait has generic methods (fn foo<T>) or methods that return Self. This is why you can't have Box<dyn Clone>.
  2. Tight Loops: If you are iterating over 1,000,000 pixels and calling a function on each one, the vtable lookup overhead will be noticeable. The compiler cannot inline dyn calls, preventing vectorization (SIMD) optimizations.
  3. Small Data Types: If your struct is just a u8, wrapping it in a Box (heap allocation) and a Fat Pointer is a massive waste of memory.

Conclusion

Generics are for Algorithms; Trait Objects are for Architecture.

  • Use Generics when writing low-level utilities, math libraries, or collections (Vec, HashMap) where the compiler needs full visibility to optimize.
  • Use Type Erasure (dyn) at the boundaries of your system modules. Use it to insulate your application logic from the specific libraries you use for logging, database access, and networking.

By strategically using dyn, you buy yourself Elasticity. You allow your system to evolve, your binaries to stay small, and your compile times to stay fast. Don't let your codebase drown in Generic Soup—sometimes, the best type is the one you erase.

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.