---
title: 'Generic Types'
source: 'https://academia.sh/en/courses/java-object-model/generic-types'
course: 'Object-Oriented Java'
language: en
updated: '2026-08-17T18:09:41+00:00'
license: 'CC BY-SA 4.0'
---

# Generic Types

The type parameter gets written to the class file in two separate places: erased in the descriptor the virtual machine reads, present in the signature attribute. The measurement shows the consequence — the check exists only at compile time, two generic types are the same class at runtime, and a write made through the raw type falls at the read site.

The previous lesson counted the members the compiler generates for a
record: accessors, equality, hash, and string conversion entered the
class file without being written in the source. If the compiler can
generate members, what does it do with the type information it holds —
does it write that somewhere too, or does it use it and let it go?

**Generics** were built in the TypeScript course around type
parameters and bounds, in terms of expressive power; this lesson does
not repeat that. The question here is this course's question: in the
writing `Store<Base>`, who decides using the `Base` information, and
when? The answer sits at an extreme in this topic — the entire
decision belongs to the **declared type**, and the runtime type has
nothing left to say. The measurement shows three consequences of this:
can two containers written with two separate type parameters be told
apart at runtime, where does the check get placed, and once the party
doing the checking is gone, who pays for a wrong write?

## The Type Parameter Gets Written to the Class File Twice

- **GE1** — The measured source text is written within the lesson.
  Since we know what we wrote, every difference found in the class
  file is the compiler's decision.
- **GE2** — The Scale core comes from the shared setup. This lesson
  adds a single measurement to it: `signature`, which returns a
  member's signature attribute. The behavior of the existing
  measurements does not change.
- **GE3** — Descriptor and signature text get printed in their raw
  form from the class file; not abbreviated, not converted to a
  readable form.

```java
// Scale.java - shared measurement core: compiles a class file and reads it
import java.io.PrintWriter;
import java.io.Writer;
import java.lang.classfile.*;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;

class Scale {
    static ClassModel compile(String source, String className) throws Exception {
        Path dir = Files.createTempDirectory("scale");
        Path file = dir.resolve(className + ".java");
        Files.writeString(file, source);
        PrintWriter silent = new PrintWriter(Writer.nullWriter());
        if (ToolProvider.findFirst("javac").orElseThrow()
                .run(silent, silent, "-d", dir.toString(), file.toString()) != 0)
            throw new IllegalStateException("did not compile: " + className);
        return ClassFile.of().parse(dir.resolve(className + ".class"));
    }

    static List<String> instructions(String source, String className, String methodName) throws Exception {
        List<String> names = new ArrayList<>();
        for (MethodModel m : compile(source, className).methods())
            if (m.methodName().stringValue().equals(methodName))
                for (CodeElement e : m.code().orElseThrow())
                    if (e instanceof Instruction i)
                        names.add(i.opcode().name().toLowerCase(Locale.ROOT));
        return names;
    }

    static String signature(AttributedElement e) {
        return e.findAttribute(Attributes.signature())
                .map(a -> a.signature().stringValue()).orElse("-");
    }
}
```

```java
// Signature.java - where the type parameter sits in the class file
import java.lang.classfile.*;

public class Signature {
    static final String STORE = """
        class Store<T> {
            private T item;
            void put(T item) { this.item = item; }
            T get() { return item; }
        }
        """;

    public static void main(String[] args) throws Exception {
        ClassModel cm = Scale.compile(STORE, "Store");
        System.out.printf("%-14s %-26s %s%n", "member", "what the VM sees", "signature attribute");
        System.out.printf("%-14s %-26s %s%n", "Store class", "-", Scale.signature(cm));
        for (FieldModel f : cm.fields())
            System.out.printf("%-14s %-26s %s%n", "field " + f.fieldName().stringValue(),
                    f.fieldType().stringValue(), Scale.signature(f));
        for (MethodModel m : cm.methods())
            System.out.printf("%-14s %-26s %s%n", "method " + m.methodName().stringValue(),
                    m.methodType().stringValue(), Scale.signature(m));
    }
}
```

```
member         what the VM sees           signature attribute
Store class    -                          <T:Ljava/lang/Object;>Ljava/lang/Object;
field item     Ljava/lang/Object;         TT;
method <init>  ()V                        -
method put     (Ljava/lang/Object;)V      (TT;)V
method get     ()Ljava/lang/Object;       ()TT;
```

The middle column is **type erasure**. Every place `T` was written in
the source has become `Object` in the class file's descriptor: the
field is `Ljava/lang/Object;`, the `put` method takes an `Object`, the
`get` method returns an `Object`. The virtual machine looks at this
column when it calls a method; to it, `Store` is an ordinary
single-field class, and there is no such thing as `T`.

The right column, though, is **not erased**. The class's signature
writes that `T` exists and that its upper bound is `Object`; the
field's signature is `TT;`, that is, "type variable `T`"; `put` and
`get`'s signatures carry the name `T` too. The information written in
the source did not disappear, it got written to a **second place**.
The constructor has no signature, because no type parameter appears in
the constructor's definition.

This gives this topic's answer to the course's question. Only the
**compiler** reads the right column; the virtual machine **executes**
the left column. Every decision about the type parameter rests on a
text the runtime type never looks at.

## The Check Goes on the Read Side, Not the Write Side

- **GE4** — The same write gets tried in three separate sources; two
  compile, one does not. The compiler's message text is not printed,
  only whether it compiled gets reported.

```java
// Check.java - what instruction a generic call leaves in the class file
public class Check {
    static final String TYPES = """
        class Base { String name() { return "base method"; } }
        class Derived extends Base { @Override String name() { return "derived method"; } }
        class Store<T> { private T item; void put(T o) { item = o; } T get() { return item; } }
        """;

    static final String USAGE = TYPES + """
        class Usage {
            static Base read(Store<Base> s) { return s.get(); }
            @SuppressWarnings("rawtypes")
            static Object rawRead(Store s) { return s.get(); }
            static void write(Store<Base> s, Derived d) { s.put(d); }
        }
        """;

    static final String WRITE =
            TYPES + "class Write { static void w(%s s) { s.put(new Derived()); } }";

    static void attempt(String label, String source) {
        try {
            Scale.compile(source, "Write");
            System.out.printf("%-30s -> compiled%n", label);
        } catch (Exception e) {
            System.out.printf("%-30s -> %s%n", label, e.getMessage());
        }
    }

    public static void main(String[] args) throws Exception {
        System.out.println("read   Store<Base> : " + Scale.instructions(USAGE, "Usage", "read"));
        System.out.println("read   raw Store   : " + Scale.instructions(USAGE, "Usage", "rawRead"));
        System.out.println("write  Store<Base> : " + Scale.instructions(USAGE, "Usage", "write"));
        System.out.println();
        attempt("Store<Base> s; s.put(Derived)", WRITE.formatted("Store<Base>"));
        attempt("Store<String> s; s.put(Derived)", WRITE.formatted("Store<String>"));
        attempt("Store s (raw); s.put(Derived)", WRITE.formatted("Store"));
    }
}
```

```
read   Store<Base> : [aload_0, invokevirtual, checkcast, areturn]
read   raw Store   : [aload_0, invokevirtual, areturn]
write  Store<Base> : [aload_0, aload_1, invokevirtual, return]

Store<Base> s; s.put(Derived)  -> compiled
Store<String> s; s.put(Derived) -> did not compile: Write
Store s (raw); s.put(Derived)  -> compiled
```

The first two lines are separated by exactly one instruction. The read
through `Store<Base>` is **four** instructions, and the fourth is
`checkcast`; the same read through the raw type is **three**
instructions, with no check. The method called is the same method in
both cases — the class file has a single `get` that returns `Object`.
What makes the difference is not the called side, it is the
**calling side**: the compiler inserts a check to fit the read value
to the declared type.

The third line is this lesson's quietest measurement. The write is
**three** instructions and carries no check at all: load the
reference, load the value, call. A check gets placed at the read site,
not at the write site. The reason is in the lower table: the write is
**already checked at compile time**. Source that writes `Derived` into
a store named `Store<String>` does not compile. The decision comes
entirely from the declared type, and since compilation stops, no work
is left for runtime.

The lower table's third row writes down the price paid. When the same
store gets named with a **raw type**, the same write compiles. The
compiler produces a warning here, but the warning does not stop
compilation; once the declared type is gone, there is no party left to
check.

## A Bound Changes the Erased Type

- **GE5** — All five attempts are built on the same bounded class;
  only the tried line changes, the types around it stay fixed.

```java
// Bound.java - a bound changes the type that gets erased
import java.lang.classfile.*;

public class Bound {
    static final String BOUNDED = """
        class Base { String name() { return "base method"; } }
        class Derived extends Base { @Override String name() { return "derived method"; } }
        class Bounded<T extends Base> {
            private T item;
            void put(T item) { this.item = item; }
            T get() { return item; }
        }
        """;

    static final String READING = BOUNDED + """
        class Reading {
            static Base baseRead(Bounded<Base> b) { return b.get(); }
            static Derived derivedRead(Bounded<Derived> b) { return b.get(); }
        }
        """;

    static void attempt(String label, String declaration, String body) {
        try {
            Scale.compile(BOUNDED + "class Forbidden" + declaration + " { " + body + " }", "Forbidden");
            System.out.printf("%-38s -> compiled%n", label);
        } catch (Exception e) {
            System.out.printf("%-38s -> %s%n", label, e.getMessage());
        }
    }

    public static void main(String[] args) throws Exception {
        ClassModel cm = Scale.compile(BOUNDED, "Bounded");
        System.out.printf("%-14s %-26s %s%n", "member", "what the VM sees", "signature attribute");
        System.out.printf("%-14s %-26s %s%n", "Bounded class", "-", Scale.signature(cm));
        for (FieldModel f : cm.fields())
            System.out.printf("%-14s %-26s %s%n", "field " + f.fieldName().stringValue(),
                    f.fieldType().stringValue(), Scale.signature(f));
        for (MethodModel m : cm.methods())
            if (!m.methodName().stringValue().equals("<init>"))
                System.out.printf("%-14s %-26s %s%n", "method " + m.methodName().stringValue(),
                        m.methodType().stringValue(), Scale.signature(m));

        System.out.println();
        System.out.println("Bounded<Base> read    : " + Scale.instructions(READING, "Reading", "baseRead"));
        System.out.println("Bounded<Derived> read  : " + Scale.instructions(READING, "Reading", "derivedRead"));

        System.out.println();
        attempt("<T> calling o.name()", "<T>", "String s(T o) { return o.name(); }");
        attempt("<T extends Base> calling o.name()", "<T extends Base>", "String s(T o) { return o.name(); }");
        attempt("new T[3]", "<T extends Base>", "Object[] d() { return new T[3]; }");
        attempt("x instanceof Bounded<Base>", "",
                "boolean t(Object x) { return x instanceof Bounded<Base>; }");
        attempt("x instanceof Bounded<?>", "",
                "boolean t(Object x) { return x instanceof Bounded<?>; }");
    }
}
```

```
member         what the VM sees           signature attribute
Bounded class  -                          <T:LBase;>Ljava/lang/Object;
field item     LBase;                     TT;
method put     (LBase;)V                  (TT;)V
method get     ()LBase;                   ()TT;

Bounded<Base> read    : [aload_0, invokevirtual, areturn]
Bounded<Derived> read  : [aload_0, invokevirtual, checkcast, areturn]

<T> calling o.name()                   -> did not compile: Forbidden
<T extends Base> calling o.name()      -> compiled
new T[3]                               -> did not compile: Forbidden
x instanceof Bounded<Base>             -> did not compile: Forbidden
x instanceof Bounded<?>                -> compiled
```

The descriptor column changed: a **bounded** type parameter erases not
to `Object`, but to its **bound**. The field is `LBase;`, `put` takes
a `Base`, `get` returns a `Base`. The signature column, though, stays
the same — it still reads `TT;`, and the class signature carries the
bound too.

This has its first consequence in the lower table's first two rows.
Source calling `o.name()` through an unbounded type parameter does not
compile, because the erased type is `Object`, and `Object` has no such
method. The same line compiles once a bound gets added. **A bound is
not documentation**; it changes the descriptor in the class file, and
widens the set of methods a call can bind to.

The second consequence is in the read lines and completes the previous
section's measurement. There is **no** `checkcast` in the read through
`Bounded<Base>`, and there **is** one in the read through
`Bounded<Derived>`. A check does not get added to every generic read;
it gets added only when the declared type and the erased type
**diverge**. In the first measurement, `Store<T>` erased to `Object`,
and a `Base` read diverged from that; here, since the erased type is
already `Base`, there is nothing left to add.

The last three lines are erasure's direct prohibitions. `new T[3]`
does not compile: an array carries its element type within itself and
checks it on every write — measured in the Java Fundamentals course —
but since no type called `T` exists at runtime, there is nothing to
carry. `x instanceof Bounded<Base>` does not compile either, while
`x instanceof Bounded<?>` does. The only question that can be asked of
the virtual machine is the raw class; a **wildcard** means exactly
"do not ask about the type parameter."

## Two Generic Types Are the Same Class at Runtime

- **GE6** — Identity gets asked only with `==`; no identity number is
  printed.
- **GE7** — The raw-typed write's result is not written as an
  assertion, it is measured with `instanceof`; only the class name
  gets printed from the caught exception.

```java
// Erasure.java - what type erasure leaves behind at runtime
import java.util.Arrays;

class Base {
    String name() { return "base method"; }
}

class Derived extends Base {
    @Override String name() { return "derived method"; }
}

class Store<T> {
    private T item;
    void put(T item) { this.item = item; }
    T get() { return item; }
}

public class Erasure {
    static Store<Base> field;

    @SuppressWarnings({"unchecked", "rawtypes"})
    public static void main(String[] args) throws Exception {
        Store<Base> carriers = new Store<>();
        Store<String> texts = new Store<>();
        System.out.println("Store<Base> and Store<String> same class : "
                + (carriers.getClass() == texts.getClass()));
        System.out.println("type parameters at runtime               : "
                + Arrays.toString(Store.class.getTypeParameters()));

        carriers.put(new Derived());
        System.out.println("runtime type of the item read            : "
                + carriers.get().getClass().getSimpleName());

        Store raw = carriers;
        raw.put("north");
        System.out.println("was the raw-type write stopped           : "
                + (carriers.get() instanceof Base ? "yes" : "no"));
        try {
            Base b = carriers.get();
            System.out.println("read result                               : " + b.name());
        } catch (ClassCastException e) {
            System.out.println("error not at the write site, at the READ site : "
                    + e.getClass().getSimpleName());
        }
        System.out.println("field's type read from the signature attribute : "
                + Erasure.class.getDeclaredField("field").getGenericType());
    }
}
```

```
Store<Base> and Store<String> same class : true
type parameters at runtime               : [T]
runtime type of the item read            : Derived
was the raw-type write stopped           : no
error not at the write site, at the READ site : ClassCastException
field's type read from the signature attribute : Store<Base>
```

The first two lines are the descriptor column's consequence.
`Store<Base>` and `Store<String>` are the **same** class at runtime,
and that class's type parameter sits under the name `T` alone, with no
value. Two separate types were written in the source; there is a
**single** class on the heap.

The third line sharpens the distinction. The object read from the
store has the runtime type `Derived` — that is, the object's own class
is known and not hidden. What is **unknown** is what the
**container** holds. An object's type exists at runtime; a
container's type parameter does not.

The fourth and fifth lines measure the price. The string written
through the raw type was **never stopped**: the store accepted it, and
the item in the store is no longer a `Base`. The error fell not at the
line where the write happened, but at the line where the **read**
happened — because the only check is the `checkcast` instruction
sitting there. This is the price a programmer looking at the wrong
side pays: the line producing the defect and the line where the defect
shows up sit in **separate places**, and the distance between them can
be as long as the program itself.

## What Gets Erased Is Not the Information, It Is the Check

The last line is this lesson's boundary measurement, and it flips the
thesis. A field declared as `Store<Base>` has its generic type
**readable** at runtime: `Store<Base>`. The information was not
erased, it sits in the signature attribute, and it can be retrieved
from there.

What gets erased, then? The first measurement's two columns give the
answer. The descriptor the virtual machine executes reads `Object`;
the virtual machine does not look at the signature attribute when it
checks a call. Type information sits in the class file as **data**,
but it does not get used as a **check**. The name "type erasure," for
this reason, describes not the information disappearing, but the
**check that rests on that information** disappearing. Reading a
field's signature and stopping a write are separate things: the first
is reading a text back, the second is work that never happens at
runtime at all.

## Summary

- The type parameter gets written to the class file in two separate
  places: erased as `Object` in the descriptor the virtual machine
  reads, written under the name `T` in the signature attribute.
- A generic read call adds a `checkcast` instruction in the class
  file; the same read through the raw type has no such instruction.
  The check sits on the calling side, not the called side.
- A generic write call leaves no check instruction, because the write
  is checked at compile time: source writing `Derived` into a
  `Store<String>` does not compile.
- A bounded type parameter erases to its bound, not to `Object`; this
  both opens up calling a method through `T`, and removes the
  `checkcast` instruction once the declared type and the erased type
  coincide.
- At runtime, `Store<Base>` and `Store<String>` are the same class; a
  raw-type write goes unstopped, and the error falls not at the write
  site, but at the read site.
- Boundary measurement: a field's generic type can be read back at
  runtime from the signature attribute. What gets erased is not the
  information, it is the check resting on that information.

## Next Step

This lesson named a store with a single type, and a difference in
checking came out between writing to that type and reading from it: an
instruction got added to the read, not to the write. The same
difference can be asked at the **type** level too. If, instead of
`Store<Base>`, we want to write "a store holding `Base` or any type
below it," does reading from that store stay as open as writing to it?
The next lesson tries four separate container forms against two
operations and counts how many of the pairs compile.
