Java's Type System and Generics
Erasure, wildcards, and variance — through the eyes of a Rust or TypeScript engineer
Learning Objectives
By the end of this module you will be able to:
- Explain how Java's nominal type system differs from TypeScript's structural system and Rust's trait-based monomorphization model.
- Describe what Java generics type erasure is, what type information is lost at runtime, and why this design decision was made.
- Apply the PECS rule (Producer Extends, Consumer Super) to design correct bounded wildcard APIs.
- Contrast Java's single shared generic implementation (via erasure) with Rust's per-instantiation monomorphization, and reason about the performance and API implications of each.
- Explain the Liskov Substitution Principle (LSP) and its role in sound subtype polymorphism within Java APIs.
Core Concepts
1. Nominal vs. Structural Typing
Java is a nominally typed language. Two types are compatible only when one explicitly declares a relationship to the other — via extends or implements. The name and declared hierarchy is what counts, not shape.
TypeScript deliberately chose the opposite approach. It is structurally typed to model JavaScript's duck-typing idioms: an object is accepted by a function if it has the right shape, regardless of what class or interface it was declared to be. You can define an anonymous object literal and pass it to a function expecting an interface, as long as the shape matches. This is described by the TypeScript team as "the only reasonable fit for JavaScript programming, where objects are often built from scratch (not from classes), and used purely based on their expected shape."
In TypeScript, two unrelated interfaces with identical shapes are interchangeable. In Java, two classes with identical fields and methods are not interchangeable unless one explicitly implements the other. This distinction has significant implications for API design: Java interfaces must be declared upfront and cannot be retrofitted onto third-party types without adapter patterns or delegation.
2. Parametric Polymorphism vs. Subtype Polymorphism
Java generics are an implementation of parametric polymorphism: you write code once over a type variable T, and the same logic applies uniformly to all types. This is in contrast with subtype polymorphism (classical OOP inheritance), where different implementations are dispatched at runtime based on the actual type.
Parametric polymorphism uses type variables to express uniformly generic behavior independent of concrete types, enabling strong static guarantees; subtype polymorphism relies on substitutability relationships where a subtype can replace a supertype, enabling runtime flexibility. Java has both: generics give you parametric polymorphism, and class hierarchies give you subtype polymorphism. You will often compose the two.
The theoretical roots of parametric polymorphism trace to System F (the second-order polymorphic lambda calculus), independently discovered by Girard (1972) and Reynolds (1974). System F formalizes the idea that a polymorphic function cannot inspect its type argument — it must behave uniformly across all instantiations. Java generics, post-erasure, broadly satisfy this constraint, though with important practical differences from the theory.
3. Type Erasure in Java
Java generics were introduced in Java 5 (2004) through a backward-compatibility constraint: the JVM bytecode format already existed and had no notion of generic types. The designers chose type erasure as the implementation strategy. At compile time, the compiler checks all generic type constraints. At compile time, generic type information is used. At runtime, it is gone: List<String> and List<Integer> both become List in bytecode.
This has concrete consequences:
- You cannot do
if (x instanceof List<String>)— the type argument is erased. - You cannot create a generic array:
new T[]is illegal. - Reflection gives you the raw class, not the parameterized type.
- At runtime, a
List<String>is just aList. Casts are inserted by the compiler at usage sites to maintain the illusion of type safety.
Erasure is not unique to Java. TypeScript's transpilation strategy also employs type erasure: all type annotations are completely removed during transpilation to JavaScript, because JavaScript has no native type system at runtime. The difference is that TypeScript erases types into a dynamically-typed runtime (JavaScript), while Java erases types into a statically-typed but unparameterized bytecode layer — a JVM that was designed before generics existed.
4. Rust Monomorphization vs. Java Erasure
Rust takes the opposite approach to Java. Generic type parameters in Rust interact with memory layout through monomorphization: each distinct instantiation of a generic type like Vec<T> receives a separate compiled version. Vec<u32> and Vec<String> produce distinct machine code, each optimized for its element type. This enables the compiler to apply different field orderings, padding strategies, and alignment rules per instantiation.
The tradeoffs are real:
| Java (erasure) | Rust (monomorphization) | |
|---|---|---|
| Binary size | One shared implementation | One copy per distinct instantiation |
| Primitive performance | Boxing required (int → Integer) | Zero-cost, primitives stored directly |
| Runtime type info | Erased | Available per instantiation |
| Compilation time | Faster | Slower for heavily generic code |
Java pays for generic reuse with boxing. Rust pays for zero-cost generics with binary bloat. Both are deliberate design choices, not accidents.
Java's Project Valhalla is addressing this historical constraint. Generic specialization (JEP 218 revision) will allow value types and primitive types to be used as generic type parameters without boxing, enabling List<int> and List<String> to have different runtime implementations optimized for their respective types. This is a multi-year effort to bring a limited form of monomorphization to the JVM without breaking backward compatibility.
5. Wildcards and Variance (PECS)
Java generics are invariant by default. List<Dog> is not a subtype of List<Animal>, even if Dog extends Animal. This surprises engineers coming from TypeScript, where structural compatibility often implies variance automatically.
The reason is soundness. If List<Dog> were a subtype of List<Animal>, you could write:
List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // hypothetical
animals.add(new Cat()); // Cat is an Animal
Dog d = dogs.get(0); // runtime ClassCastException
Java instead expresses variance explicitly, at use-sites, via wildcards:
List<? extends Animal>— a list of some unknown subtype ofAnimal. You can readAnimalfrom it, but not safely write to it (because you don't know the exact type).List<? super Dog>— a list of some unknown supertype ofDog. You can safely writeDog(or a subtype) into it, but reading from it yields onlyObject.
The mnemonic PECS — Producer Extends, Consumer Super — captures the design rule:
- If a generic parameter is a producer (the code reads from it), use
? extends T. - If a generic parameter is a consumer (the code writes to it), use
? super T.
// Copies elements from src (producer) into dest (consumer)
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (T t : src) {
dest.add(t);
}
}
This is the API that Java's own Collections.copy uses.
6. Bounded Type Parameters
Beyond wildcards (use-site variance), Java supports bounded type parameters at the declaration site:
public <T extends Comparable<T>> T max(List<T> list) { ... }
Here T extends Comparable<T> constrains T at the call site. Engineers from Rust will recognize this as analogous to trait bounds (T: Ord). Engineers from TypeScript will recognize it as a constraint (T extends Comparable). The syntax is similar; the semantics differ in that Java bounds express nominal subtype relationships, not structural ones.
Multiple bounds are written with &:
public <T extends Serializable & Comparable<T>> void persist(T value) { ... }7. The Liskov Substitution Principle
Subtype polymorphism in Java is governed — at the behavioral level — by the Liskov Substitution Principle (LSP), introduced by Barbara Liskov in 1987: a subtype S is substitutable for supertype T if all program properties remain valid when S instances replace T instances.
LSP is stricter than just "does the compiler accept it?" It requires that:
- Preconditions are not strengthened in the subtype.
- Postconditions are not weakened in the subtype.
- Invariants of the supertype are preserved.
The canonical violation example is Square extends Rectangle. Mathematically, a square is a rectangle. But if Rectangle has independent setWidth and setHeight methods, a Square that synchronizes them breaks the behavioral contract callers of Rectangle expect.
The Java compiler won't catch LSP violations. They are behavioral, not syntactic. Code that extends a class (or implements an interface) must respect the documented contracts of the supertype — even when those contracts are not expressed as types. This is why interface documentation matters in Java APIs.
Compare & Contrast
Nominal (Java) vs. Structural (TypeScript) Typing
In TypeScript, the following works without any implements declaration:
interface Named { name: string }
function greet(x: Named) { console.log(x.name); }
const obj = { name: "Alice", age: 30 };
greet(obj); // Fine: obj has the right shape
In Java, this requires explicit declaration:
interface Named { String name(); }
// obj must explicitly implement Named — there is no duck-typing
TypeScript adopted structural typing specifically to model JavaScript's duck-typing idioms and enable anonymous objects without explicit type declarations. Java's nominal typing, by contrast, provides stronger guarantees about intent: when a class says implements Comparable, it is making a deliberate semantic commitment, not just a shape coincidence.
Java Generics vs. Haskell/Rust Parametricity
Type classes (Haskell) and trait bounds (Rust) provide ad-hoc polymorphism that differs fundamentally from purely parametric polymorphism. A parametric function behaves uniformly across all types. A function constrained by a type class or trait can dispatch to different implementations based on the concrete type — this is a controlled, explicit form of type-specific behavior.
Java's bounded type parameters (T extends Comparable<T>) sit in this space: the T is still erased at runtime, but at compile time, the bound enables method dispatch. It is weaker than Rust's trait system (no blanket implementations, no coherence rules) but stronger than raw object casting.
Variance: Java Wildcards vs. TypeScript Declaration-Site Variance
TypeScript supports declaration-site variance annotations (in, out, in out on type parameters). Java uses use-site variance only (wildcards at call sites). The Java approach is more verbose but more flexible: you can express different variance requirements for the same type in different call sites.
Common Misconceptions
"Generics give me runtime type information."
They do not. Type erasure removes all generic type arguments at compile time. At runtime, List<String> and List<Integer> are both List. Casting and instanceof checks operate on the raw type. If you need runtime type information, you must pass a Class<T> token explicitly.
"List<Dog> is a subtype of List<Animal> because Dog is a subtype of Animal."
This is false. Java generics are invariant by default. Subtype polymorphism relies on substitutability relationships where a subtype can replace a supertype, but that substitutability does not automatically lift through generic containers. Use wildcards (List<? extends Animal>) when you need covariant read-only access.
"Wildcards and bounded type parameters are the same thing."
They address related but distinct problems. Bounded type parameters (<T extends Animal>) are declaration-site constraints that give you a named type variable you can refer to elsewhere in the signature. Wildcards (? extends Animal) are use-site constraints that say "some unknown subtype, and I don't need to name it." You cannot use a wildcard where you need to relate two occurrences of the same type (e.g., a method that takes and returns the same T).
"Java's type erasure is a flaw; TypeScript's erasure is different and better." Both languages erase types, for different reasons. Java erases to remain compatible with a pre-generics JVM; TypeScript erases because JavaScript has no native type system at runtime, making runtime type preservation impossible. The mechanisms differ, but the core constraint — types exist only at compile time — is shared.
"LSP is just another way of saying 'your subclass must compile.'" LSP is a behavioral guarantee, not a syntactic one. Behavioral subtyping is stronger than structural subtyping, additionally requiring preservation of preconditions, postconditions, and invariants. The compiler will accept many LSP violations without complaint.
Worked Example
Designing a PECS-correct API
Suppose you are building a utility method that copies elements from one collection into another, with a filter predicate:
public static <T> void filteredCopy(
List<? extends T> source, // producer: we read T from it
List<? super T> destination, // consumer: we write T into it
Predicate<? super T> filter // consumer: we pass T into it
) {
for (T element : source) {
if (filter.test(element)) {
destination.add(element);
}
}
}
Now consider how the call site looks:
List<Dog> dogs = List.of(new Dog("Rex"), new Dog("Spot"));
List<Animal> animals = new ArrayList<>();
Predicate<Animal> isLarge = a -> a.weight() > 30;
filteredCopy(dogs, animals, isLarge);
dogs(List<Dog>) is passed asList<? extends T>. SinceDogextendsAnimal(andTis inferred asDogorAnimal), this works.animals(List<Animal>) is passed asList<? super T>.Animalis a supertype ofDog, so writingDoginto it is safe.isLarge(Predicate<Animal>) is passed asPredicate<? super T>. A predicate that acceptsAnimalcan certainly acceptDog.
The Java compiler infers T = Dog here. List<? super Dog> is satisfied by List<Animal> because Animal is a supertype of Dog. This is the PECS rule working correctly: the source produces Dogs, the destination consumes them.
What breaks if you get variance wrong?
If you write List<T> instead of List<? extends T> for the source:
public static <T> void filteredCopy(
List<T> source, // invariant: forces exact match
List<? super T> destination,
Predicate<? super T> filter
) { ... }
The call filteredCopy(dogs, animals, isLarge) now fails to compile. The compiler cannot reconcile List<Dog> with List<Animal> as the type for T. You have accidentally made your API less flexible than it needs to be.
Bounded type parameters for algorithms
When you need to refer to T multiple times in a signature, use a bounded type parameter, not a wildcard:
// Returns the larger of the two values — T must be comparable
public static <T extends Comparable<T>> T max(T a, T b) {
return a.compareTo(b) >= 0 ? a : b;
}
This cannot be expressed with wildcards. ? extends Comparable<?> would not give you a way to say "the same type on both sides."
Key Takeaways
- Java is nominally typed; TypeScript is structurally typed. In Java, compatibility requires an explicit declared relationship (extends or implements). Shape alone is not enough. This makes Java APIs more explicit and intent-revealing, but less flexible for ad-hoc composition with third-party types.
- Java generics use type erasure. Generic type arguments are verified at compile time and erased at runtime. At runtime, List<String> is just List. You cannot use generic type arguments in instanceof checks, array creation, or reflection without explicit type tokens.
- Java erasure vs. Rust monomorphization. Rust generates a separate compiled version for each distinct generic instantiation, enabling zero-cost primitives and per-instantiation layout optimization at the cost of binary bloat. Java generates one shared implementation, requiring boxing for primitives. Project Valhalla is progressively closing this gap.
- PECS is the correct mental model for wildcards. Producer Extends, Consumer Super. A method that reads from a collection uses ? extends T; a method that writes into it uses ? super T. Getting variance wrong makes APIs unnecessarily restrictive.
- LSP is a behavioral contract, not a compiler guarantee. A subclass that the compiler accepts may still violate LSP by strengthening preconditions, weakening postconditions, or breaking invariants. Sound subtype design requires discipline beyond what the type checker enforces.
Further Exploration
On Java Generics and Type Erasure
- Specialized Generics — Brian Goetz (Project Valhalla) — The design rationale for moving beyond erasure in the JVM.
- JEP 402: Enhanced Primitive Boxing (Preview) — Concrete progress toward generics over value types.
On Structural vs. Nominal Typing
- TypeScript: Type Compatibility — The TypeScript team's own explanation of why structural typing was chosen.
- Understanding TypeScript (Bierman, Abadi, Torgersen) — Academic paper formalizing TypeScript's type system, including structural compatibility.
On Rust Monomorphization and Memory Layout
- repr(Rust) — The Rustonomicon — How Rust's default representation interacts with monomorphization and field layout.
On Parametric Polymorphism Theory
- The Girard–Reynolds Isomorphism (second edition) — The theoretical foundation behind why parametric functions must behave uniformly.
- Parametric polymorphism — Wikipedia — Accessible overview situating parametric and subtype polymorphism.
On LSP
- A Behavioral Notion of Subtyping — Liskov and Wing, CMU — The original paper. Dense but foundational.
- Liskov Substitution Principle — Northeastern — Practical course notes with concrete examples.