Type Implspec

Type Erasure and Reification

Generic type arguments can be thrown away after checking, kept as runtime metadata, or compiled into separate specialised bodies. The choice decides what reflection can see, what casts cost, and which perfectly reasonable programs the language has to forbid.

The question

Where did my generic type go at runtime, and why can I not write new T[]?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

After type checking, a generic definition can be lowered three ways: erase the type arguments and keep one shared body (Java, TypeScript), keep them as runtime metadata attached to instantiated types (C#, CLR), or emit a separate body per instantiation (Rust, C++). What survives decides what reflection, dispatch, allocation and specialization can do afterwards — and what is erased cannot be recovered by any later phase.

What this phase may assume or do

Erasure preserves behavior only if no operation in the language can observe a type argument at runtime. Where one can — a cast, an instanceof, an array store, a default(T), a T::new — the compiler must either synthesise a check at the erasure boundary from information it does have, or the language must forbid the operation outright. Java does both: it inserts checkcasts at call sites and it forbids new T[].

Key points

  • Three strategies: erase to a bound and insert casts, reify as runtime metadata, or monomorphize into separate bodies.
  • Java erases because binary compatibility with pre-generics bytecode was the overriding requirement in 2004.
  • After erasure there is no String in List<String>; every consequence — no new T[], no parameterised instanceof, no static T field, colliding overloads — follows from that.
  • new T[] is forbidden because arrays are reified and generics are not, so the array could not perform the store check it is required to perform.
  • javac compensates with inserted checkcast instructions and with synthetic bridge methods, both of which are visible in bytecode and stack traces.
  • Reification costs runtime complexity, metadata and per-instantiation JIT work; it buys unboxed value collections and reflection that can see type arguments.
  • Erasure's largest practical costs are deserialization (nothing knows the element type) and boxing (a collection of pointers rather than of values).

Three ways to lower a generic

A generic definition is a single piece of source that stands for many programs. The compiler has to decide what to hand to the runtime, and there are only three shapes of answer.

Erase. Replace each type parameter with its bound (Object if unbounded), compile one body, and insert casts wherever the checked type is needed. This is what Java does, and it was chosen in 2004 for one overriding reason: existing bytecode and existing libraries had to keep working, and a List compiled before generics existed had to be assignable to a List<String> compiled after. Erasure buys that migration exactly.

Reify. Keep the type arguments in the runtime type. List<string> and List<int> are different runtime types on the CLR, each with its own type handle, and the JIT specialises value-type instantiations while sharing one body for all reference types. Reification buys typeof(T), new T(), default(T), unboxed collections of primitives, and reflection that can see the type argument.

Monomorphize. Emit a separate compiled body per instantiation, with the type argument substituted throughout. Rust and C++ templates do this. It buys everything reification does plus full specialisation of the code — see [[monomorphization]], which is the lesson for that half.

Erase entirely. A fourth cell exists and TypeScript occupies it: the types are removed and *nothing* is inserted. No casts, no metadata, no checks. That is not a weaker version of Java's erasure — Java at least synthesises checks — it is the complete absence of runtime type information, which is the subject of [[gradual-typing]].

What survives to runtime, and what each choice makes possibleimplementation
LanguageStrategyVisible at runtimeEnabledForbidden or costly
JavaspecErase to bound, insert castsThe raw class only — List, not List<String>Binary compatibility with pre-generics code; one body per definitionnew T[], instanceof List<String>, T.class; primitives must box
C# / CLRimplementationReify; JIT shares reference instantiations, specialises value onesList<int> and List<string> are distinct runtime typestypeof(T), default(T), new T(), unboxed List<int>Runtime and metadata complexity; JIT work per value instantiation
RustimplementationMonomorphize per instantiationNothing generic remains — each instantiation is a concrete functionFull specialisation, inlining, known layouts, no boxingCode size, compile time; no runtime generic reflection at all
C++ templatesspecInstantiate per argument set at compile timeNothing generic remainsSpecialisation, constexpr evaluation, zero-overhead abstractionCode size, compile time, notoriously poor diagnostics
Go 1.18+implementationGC shape stenciling plus dictionariesOne body per GC shape; the dictionary carries the restBounded code growth, fast buildsLess specialisation than full monomorphization
TypeScriptimplementationErase completely; insert nothingNothing at allZero runtime cost, zero output differenceNo runtime type information whatsoever

What Java erasure actually costs

specErasure is specified in the JLS: type parameters are replaced by their leftmost bound, and the resulting signatures are what the JVM sees. It is a language rule rather than a javac behaviour, which is why no alternative Java compiler can implement generics differently, and why Project Valhalla — which aims to give the JVM specialised generics over value types — is a decade-long change to the platform rather than a compiler flag.

The famous consequences are all corollaries of one fact: at runtime there is no String in List<String>. getClass() on it returns java.util.List. instanceof List<String> does not compile, because there is nothing to test. Two overloads differing only in type argument have the same erased signature and collide. A generic class cannot have a static field of type T, because there is only one class and one field.

new T[] is forbidden for a subtler reason and it is worth following, because it explains why erasure and Java's array covariance are entangled. Arrays in Java are reified — an array object knows its component type and checks every store, as [[type-soundness]] describes. Generics are erased. So a T[] created inside a generic class would have to pick a real component type at runtime, and the only one available after erasure is Object. Handing that out as a T[] would let the caller store the wrong thing with no check firing where it should. The language forbids the construction rather than permit the hole, which is why every generic collection in the JDK holds an Object[] internally and casts on the way out.

The compiler compensates by inserting casts. When you call list.get(0) and assign to a String, javac emits a checkcast — the check the type system already proved unnecessary, present because the bytecode has no other way to know. Those casts are usually cheap and are frequently eliminated by the JIT, but they are real instructions and they are the visible price of the strategy.

Bridge methods are the other synthesised artifact. When a class implements Comparable<Foo>, the erased signature of compareTo takes Object, but the class wants to declare it taking Foo. javac therefore emits both: the real method and a synthetic bridge with the erased signature that casts and delegates. They show up in stack traces and in reflection, and they surprise people who expected the class to have the methods they wrote.

The same source, before and after erasure
1// Source
2class Box<T extends Comparable<T>> {
3 private T value;
4 T get() { return value; }
5 int cmp(T other) { return value.compareTo(other); }
6}
7String s = box.get();
8
9// After erasure, as the bytecode sees it
10class Box {
11 private Comparable value; // T replaced by its bound
12 Comparable get() { return value; }
13 int cmp(Comparable other) { return value.compareTo(other); }
14}
15String s = (String) box.get(); // checkcast inserted at the call site

The type parameter is gone and the cast has moved from the type checker into the instruction stream. The generic signature survives in a Signature attribute for the benefit of the compiler and reflection, but nothing in the executed code consults it.

What reification costs, and why not everyone chose it

Reification is not free, and reading Java's decision as simply worse misses what was on the table. Keeping type arguments at runtime means the runtime must model them: a List<int> needs a distinct type handle, a distinct method table, and JIT work to produce a specialised body. The CLR does exactly this, sharing one native body across all reference-type instantiations (since a reference is a reference) and generating a separate one per value type. That is why List<int> in C# stores unboxed integers and List<Integer> in Java stores pointers to boxed objects — a genuine and often large memory and locality difference.

The costs are a larger and more complicated runtime, more metadata in every assembly, JIT time proportional to the number of distinct value instantiations, and — the decisive one for Java — no way to keep binary compatibility with a decade of existing non-generic bytecode. The CLR could reify because generics arrived in version 2.0 of a runtime the same team controlled and could change. Java could not change the JVM without invalidating every existing class file.

So the honest comparison is: erasure bought Java a migration that would otherwise have been impossible, and the bill arrives every day thereafter in boxing, in forbidden operations, and in reflection that cannot see what the source clearly said. Reification bought C# better data layout and a more capable reflection story, and pays in runtime complexity that has to be maintained forever.

Rust and C++ take the third road and pay in a different currency entirely: code size and compile time, in exchange for no runtime type machinery at all. That trade has its own lesson.

Where this bites in practice

The recurring practical problem is deserialization, and it is the same problem in every erased language. To turn JSON into a List<User> something must know, at runtime, that the element type is User — and after erasure nothing does. Java's libraries work around it with the type-token idiom: an anonymous subclass whose generic superclass signature survives in the class file, from which reflection can recover the argument. That is a workaround for exactly this loss, and the reason it looks strange is that it is smuggling information past an erasure boundary.

TypeScript has the same problem in a more acute form, since it erases everything: a function generic in T cannot validate that a parsed value is a T, because at runtime there is no T to compare against. The ecosystem's answer is to make the schema the runtime artifact and derive the static type from it, which is the pattern [[gradual-typing]] describes and which exists precisely because erasure left nothing to check.

The second recurring bite is performance, and it is quieter. A List<Integer> in Java is an array of pointers to heap-allocated boxes; iterating it chases a pointer per element and defeats prefetching. The C# equivalent is a contiguous array of 32-bit integers. For a million elements that is not a micro-optimization, it is a different memory profile — and it is a direct, unavoidable consequence of the erasure decision, not something a JIT can fully repair.

Smuggling a type argument past erasure
1// Fails: nothing at runtime knows the element type.
2List<User> users = mapper.readValue(json, List.class); // unchecked, and wrong
3
4// The type-token idiom: an anonymous subclass whose generic
5// superclass signature is recorded in the class file.
6List<User> users = mapper.readValue(json,
7 new TypeReference<List<User>>() {}); // note the {} — a subclass
8
9// Class.getGenericSuperclass() can then recover List<User>,
10// because that signature was written into a class rather than
11// into a value.

The empty braces are load-bearing. Without them there is no subclass, no Signature attribute, and no recoverable type argument — which is a precise demonstration of what erasure removed and where the residue survives.

How it works

The steps, in the order the compiler takes them.

  • The type checker runs with full generic information and records the results; nothing after this point needs the arguments to be correct, only present where required.
  • For erasure, each type parameter is replaced by its leftmost bound throughout the signature and body, producing one class file per source class.
  • At every point where the erased type must be narrowed back — a call returning T assigned to a concrete type — the compiler inserts a checked cast.
  • Where an overriding method's erased signature differs from the overridden one, the compiler emits a synthetic bridge method with the erased signature that casts and delegates.
  • The original generic signature is preserved in a side-table attribute so that compilers and reflection can read it, while the executed instructions ignore it.
  • For reification, the runtime creates a distinct type handle per instantiation; a JIT may share one native body across all reference instantiations and generate a distinct one per value type.
  • For monomorphization, the compiler substitutes the argument into a fresh copy of the body per instantiation, and the linker deduplicates identical copies emitted in several translation units.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • JSON deserializes into a List whose elements are LinkedHashMap rather than the declared element type; the failure appears as a ClassCastException at the first field access, several methods after the parse.
  • Two overloads that differ only in type argument fail to compile with "have the same erasure", and the error names a conflict the source does not visibly contain.
  • An unchecked-cast warning is suppressed to make a build clean, and a ClassCastException appears months later at a line that merely reads from a collection.
  • A stack trace or a reflection listing contains a method the developer never wrote — a synthetic bridge — and time is lost working out where it came from.
  • A List<Integer> holding ten million elements uses several times the memory of the equivalent primitive array and shows up as a GC pressure problem rather than as a typing one.
  • A generic TypeScript function is trusted to validate its argument; nothing is checked at runtime because nothing survived, and a wrongly shaped value flows on with a correct-looking type.

When it helps

  • Migration: erasure is what allows generic and non-generic code to interoperate in one program, which was the entire point.
  • Keeping one compiled body per definition, which bounds code size and keeps build times flat as instantiations multiply.
  • Reification helps wherever the type argument is genuinely needed at runtime: deserialization, dependency injection, reflection-driven frameworks, and unboxed collections of primitives.
  • Reification also helps performance in data-heavy code, where a contiguous array of values beats an array of pointers to boxes by a wide margin.

When it hurts

  • Anywhere the type argument is needed at runtime in an erased language: serialization, generic factories, T-typed arrays, dependency injection by type.
  • In numeric or data-intensive code on an erased platform, where boxing turns a compact array into a pointer chase.
  • Reification hurts when the number of value-type instantiations is large, since each one costs JIT time and code cache.
  • Complete erasure with no inserted checks hurts at every trust boundary, because the type information the code appears to rely on does not exist when the data arrives.

What it costs

Every one of these is paid by something.

  • Erasure buys binary compatibility with pre-generic code and one compiled body per definition, and pays with boxing, forbidden operations, colliding signatures, and a permanent hole in what reflection can see.
  • Reification buys runtime type information and unboxed value instantiations, and pays in runtime and metadata complexity plus JIT time proportional to the number of distinct value instantiations.
  • Monomorphization buys full specialisation and no runtime type machinery at all, and pays in code size, compile time and instruction-cache pressure — see [[monomorphization]].
  • Complete erasure with no inserted checks buys zero runtime cost and byte-identical output, and pays by leaving nothing whatsoever to check against at a boundary — the guarantee ends at compile time.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Reified generics as on the CLR: better layout and reflection, at the price of a runtime that has to model type arguments forever. The right choice when you control the runtime and can change it.
  • Monomorphization as in Rust and C++: no runtime type machinery, maximum specialisation, and a code-size and compile-time bill instead.
  • Go's GC shape stenciling: one body per class of layout, plus a dictionary carrying the per-instantiation details — a deliberate middle that bounds code growth at the cost of some specialisation.
  • Passing an explicit runtime type witness — Class<T>, a TypeReference, a schema object — reconstructs by hand exactly what erasure removed, at the cost of an extra parameter on every affected API.

See it for yourself

The flag, dump or tool that shows you this directly.

  • javap -c -p ClassName shows the erased signatures, the inserted checkcast instructions and the synthetic bridge methods, all in one listing — the single most convincing demonstration of erasure.
  • javap -v additionally prints the Signature attribute, which is where the generic information survives for compilers and reflection while the code ignores it.
  • ildasm or ILSpy on a .NET assembly shows generic type arguments present in the metadata, which is the same experiment run on the reifying side.
  • cargo build --release followed by nm -C target/release/… | grep <fn> shows one symbol per instantiation for Rust — erasure's opposite, made visible.
  • For TypeScript, run tsc and diff input against output: the generics are simply gone, and nothing takes their place.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "Java generics are fake." They are fully checked at compile time and the checking is real. What is absent is the runtime representation, which is a lowering decision, not an absence of typing.
  • "Erasure is a mistake nobody would repeat." It was the only strategy compatible with existing bytecode, and every language retrofitting generics onto a deployed runtime faces the same choice. TypeScript made the same one for the same reason.
  • "Reified generics are strictly better." They cost runtime complexity and per-instantiation JIT work, and they were only possible because the CLR could be changed. Strictly better in isolation; not available to Java in 2004.
  • "The cast the compiler inserts is redundant, so it is free." It is an instruction that executes. The JIT frequently removes it and is not obliged to, and in interpreted or cold code it is genuinely there.

Misconceptions

The claim, and what is actually true.

Generic type information is available via reflection in Java.
Only where it was written into a class or a field or a method signature, which the Signature attribute preserves. It is not available for the type argument of an object you are holding, which is why the type-token idiom exists.
Erasure means generics have no runtime cost.
Erasure means no runtime type information. The cost moved to inserted casts and, far more expensively, to boxing every primitive that goes into a generic container.
TypeScript erasure and Java erasure are the same mechanism.
Java erases to a bound and inserts checks that keep the program well-behaved. TypeScript erases and inserts nothing. One preserves a guarantee with a runtime cost; the other ends the guarantee at compile time.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

Generic types are checked at compile time, and then the compiler decides what to do with them. Java throws the type arguments away and inserts casts, so at runtime a List<String> is just a List. C# keeps them, so List<int> is a real distinct type that stores real integers. Rust compiles a separate copy of the code for each type it is used with. Each choice makes different things possible later.

practical

The rule of thumb in an erased language is that anything needing the type argument at runtime needs the argument passed explicitly. Deserialization is the usual case: pass a Class<T> or a TypeReference<List<User>>, or in TypeScript make the schema the runtime object and derive the static type from it. The second thing to watch is boxing — List<Integer> is an array of pointers to heap objects, and in numeric code the difference from a primitive array is measured in multiples, not percentages. Reach for a primitive-specialised collection when the element count is large.

advanced

The three strategies are really one question asked at three different times: when is a type argument bound to a body? Erasure binds never — one body serves all instantiations and the argument exists only during checking. Reification binds at runtime, on first use, which is why the CLR can specialise List<int> lazily and why that costs JIT time. Monomorphization binds at compile time, which is why it produces the fastest code and the largest binaries. The interesting consequence is what each enables downstream: only the last two let a later phase know a concrete layout, and knowing the layout is the precondition for devirtualization, for inlining across a generic boundary, and for storing values inline instead of behind a pointer. That is why Project Valhalla is hard — retrofitting the last two properties onto a platform that chose the first requires the JVM to learn a distinction it was specified not to make.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

specJava erasure is specified in the JLS, including the replacement of a type parameter by its leftmost bound and the resulting signature collisions. It is therefore not a javac implementation detail and cannot be changed by a different compiler — changing it requires changing the platform, which is what Project Valhalla is attempting for value types.
implementationThe CLR's strategy is shared native code for all reference-type instantiations and separate specialised code per value-type instantiation. That is a runtime implementation decision, not a C# language rule, and it means the cost of adding a new instantiation depends on whether the argument is a class or a struct — a distinction with no counterpart in Java, where every instantiation shares one erased body.
implementationThat javac inserts checkcast at erasure boundaries and emits synthetic bridge methods is observable in any class file via javap -c, and the number and placement of those instructions varies between javac versions and between javac and ecj. Whether the JIT eliminates a given cast is a HotSpot decision dependent on profile and inlining, so their runtime cost is real in cold code and usually not in hot code.
implementationGo 1.18 and later implement generics by GC shape stenciling — one compiled body per class of types sharing a garbage-collector layout — with a dictionary parameter supplying per-instantiation information. This is a compiler implementation choice that has changed since introduction and may change again; the language specification does not mandate it.

If you were asked this in an interview

  • Why can you not write new T[10] in a Java generic class? Give the reason in terms of what arrays check and what generics know.
  • What does javac insert at an erasure boundary, and what would you look at to see it?
  • Java erases and C# reifies. Explain what each bought and what each pays, and say why Java could not have chosen the other one.

Connections