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
- Refactoring Paralysis: If you want to add a
Metricstrait to theClient, you have to update the type signature ofClient,ConnectionPool,RequestHandler, andhandle_request. A 5-minute change becomes a 5-hour refactor spanning 20 files. - Unreadable Errors: Generic trait bound errors are notoriously difficult to decipher, often spanning multiple screen heights.
- API Rigidity: This is the killer. The types
TandPare now exposed in the public API.Client<Tcp, Json>is a different type fromClient<Quic, Json>. You cannot store them in the sameVec. 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).
data_ptr(64 bits): Points to the actualMyStructdata in memory.vtable_ptr(64 bits): Points to a vtable (Virtual Method Table) specifically generated for the combination ofMyStructandMyTrait.
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.


