PrepZone Logo
PrepZone

Generics and Type Safety

Type parameters, bounds, wildcards and erasure — the foundation every collection is built on.

Why this matters

  • Without generics every collection returns Object and every read needs a cast, so a wrong cast becomes a run-time crash instead of a compile error.
  • Wildcards look like syntax noise until you need to write a method that accepts "a list of any kind of number", and then they are the only answer.
  • Erasure explains a handful of restrictions that otherwise seem arbitrary, and it is a reliable interview question.

The problem generics solve

Java
// Pre-generics: the compiler cannot help you
List names = new ArrayList();
names.add("Ana");
names.add(42);                                 // accepted — it is all Object
String first = (String) names.get(0);          // cast required
String second = (String) names.get(1);         // ClassCastException at run time

// With generics: the mistake is a compile error
List<String> safe = new ArrayList<>();
safe.add("Ana");
// safe.add(42);                               // compile error, caught immediately
String value = safe.get(0);                    // no cast needed

The second version fails in your editor rather than in production. That is the entire value.

Generic classes and methods

Java
public class Box<T> {
    private T content;

    public void put(T content) { this.content = content; }
    public T get()             { return content; }
}

Box<String> text = new Box<>();     // the diamond infers String from the declaration
text.put("hello");
String value = text.get();           // typed, no cast

A generic method declares its own type parameter before the return type, which lets it be used independently of the class.

Java
public static <T> List<T> repeat(T element, int times) {
    List<T> result = new ArrayList<>();
    for (int i = 0; i < times; i++) result.add(element);
    return result;
}

// Two parameters, and a return type derived from both
public static <K, V> Map<V, K> invert(Map<K, V> source) {
    Map<V, K> result = new HashMap<>();
    source.forEach((key, value) -> result.put(value, key));
    return result;
}

Conventional parameter names

  • T — type. The default for a single parameter.
  • E — element. Used throughout the collections.
  • K, V — key and value, for maps.
  • R — result, for a function's return type.
  • N — number.

Bounded type parameters

extends on a type parameter means "this type or a subtype", and it unlocks the bound's methods inside the class.

Java
// Without a bound, T could be anything, and doubleValue() would not compile
public static <T extends Number> double sum(List<T> numbers) {
    double total = 0;
    for (T number : numbers) {
        total += number.doubleValue();        // available because of the bound
    }
    return total;
}

Multiple bounds are separated by &, with the class bound first if there is one:

Java
public static <T extends Comparable<T> & Serializable> T largest(List<T> items) {
    return items.stream().max(Comparator.naturalOrder()).orElseThrow();
}

Wildcards

A wildcard appears where you use a generic type rather than where you declare one. It is how you accept a family of parameterisations.

? extends Number — producer
Read is allowedNumber n = list.get(0)
Write is blockedlist.add(1) will not compile

You know everything coming out is at least a Number.

? super Integer — consumer
Write is allowedlist.add(42)
Read gives ObjectExact type is unknown

You know the list can hold an Integer safely.

Use extends when you only read from the structure and super when you only write into it. The compiler blocks the other direction to keep the collection type-safe.

The guiding rule is PECS — Producer Extends, Consumer Super.

AspectWildcardWhat you can do
List<? extends Number>Read as Number — a producerCannot add anything except null
List<? super Integer>Add Integer and its subtypes — a consumerReads come back as Object
List<?>Read as Object, check size, clearCannot add anything except null
List<Object>Read and write ObjectAccepts only an actual List<Object>
  • List<? extends Number>

    WildcardRead as Number — a producer
    What you can doCannot add anything except null
  • List<? super Integer>

    WildcardAdd Integer and its subtypes — a consumer
    What you can doReads come back as Object
  • List<?>

    WildcardRead as Object, check size, clear
    What you can doCannot add anything except null
  • List<Object>

    WildcardRead and write Object
    What you can doAccepts only an actual List<Object>

If you read from the parameter use extends; if you write into it use super.

Java
// Producer: we only read, so extends widens what callers may pass
public static double total(List<? extends Number> numbers) {
    double sum = 0;
    for (Number n : numbers) sum += n.doubleValue();
    return sum;
}

total(List.of(1, 2, 3));            // List<Integer> — accepted
total(List.of(1.5, 2.5));           // List<Double>  — accepted

// Consumer: we only write, so super widens what callers may pass
public static void fillWithDigits(List<? super Integer> target) {
    for (int i = 0; i < 10; i++) target.add(i);
}

fillWithDigits(new ArrayList<Integer>());   // accepted
fillWithDigits(new ArrayList<Number>());    // accepted
fillWithDigits(new ArrayList<Object>());    // accepted

Generics are not covariant

This is the most important difference from arrays.

Java
// Arrays are covariant — and that is a hole in the type system
Object[] objects = new String[3];
objects[0] = 42;                     // compiles, throws ArrayStoreException at run time

// Generics are invariant — the hole is closed at compile time
// List<Object> list = new ArrayList<String>();   // compile error

List<String> is not a subtype of List<Object>, even though String is a subtype of Object. If it were, you could add an Integer to a List<Object> reference that actually pointed at a List<String>. Invariance is what makes the compile-time guarantee real, and wildcards are the controlled way to relax it when you need to.

Type erasure

The compiler checks your types and then removes them. At run time there is one ArrayList class, not one per parameterisation.

Java
List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();

System.out.println(strings.getClass() == integers.getClass());   // true — same class
System.out.println(strings.getClass().getSimpleName());           // ArrayList

Unbounded parameters erase to Object; bounded ones erase to their bound. The compiler inserts casts at the call sites where they are needed, so the code remains type-safe from the outside.

What erasure forbids

  • new T() — no type information exists at run time, so there is nothing to instantiate. Pass a Supplier<T> or a Class<T> instead.
  • new T[10] — same reason. Create an Object[] and cast, or use Array.newInstance with a Class<T>.
  • value instanceof List<String> — the parameter is gone; only instanceof List<?> is allowed.
  • Primitive type arguments — List<int> is invalid, because erasure needs a reference type. Use List<Integer>.
  • Overloads differing only by type argument — process(List<String>) and process(List<Integer>) erase to the same signature and will not compile.
  • A generic type in a catch — exception matching happens at run time, which erasure makes impossible.
  • static fields of a parameterised type — one field is shared by all parameterisations, so T has no meaning there.
Java
// The workaround for new T(): pass a factory
public static <T> List<T> createMany(Supplier<T> factory, int count) {
    List<T> result = new ArrayList<>();
    for (int i = 0; i < count; i++) result.add(factory.get());
    return result;
}

List<ArrayList<String>> lists = createMany(ArrayList::new, 3);

Raw types

Using a generic class without a type argument reverts to pre-generics behaviour and disables checking for that whole reference.

Java
List raw = new ArrayList<String>();
raw.add(42);                              // unchecked warning, compiles

List<String> strings = raw;               // unchecked warning
String broken = strings.get(0);           // ClassCastException here, far from the cause

Never use raw types in new code. When you must call a legacy API that returns one, convert it as soon as possible and confine the warning:

Java
@SuppressWarnings("unchecked")             // narrowest possible scope, with a reason
List<String> safe = (List<String>) legacyApi.fetch();

Common misreadings

  • "List<String> and List<Integer> are different classes at run time." They are the same class. Erasure leaves one.
  • "List<Object> accepts any list." It accepts only an actual List<Object>. For any list, use List<?>.
  • "You can add to a List<? extends Number>." Only null. The element type is unknown.
  • "Arrays and generics behave the same way." Arrays are covariant and fail at run time; generics are invariant and fail at compile time.
  • "The diamond <> is optional sugar." It is inference. Omitting the type argument entirely gives you a raw type, which is different.
  • "A raw type is just a shorter spelling." It disables generic checking for every use of that reference.

Quick recall

Everything you need if you only revisit this box.

  • Generics move type errors from run time to compile time, and remove casts.
  • Bound a parameter with extends to use the bound's methods; combine bounds with &.
  • PECS — Producer extends, Consumer super. Read from ? extends, write into ? super.
  • You cannot add anything but null to a ? extends collection.
  • Generics are invariant: List<String> is not a List<Object>. Arrays are covariant and unsafe.
  • Erasure removes type arguments, so no new T(), no new T[], no instanceof List<String>, no primitives, and no overloads differing only by type argument.
  • Never use raw types. They disable checking and move the failure far from its cause.

Test yourself

Answer these before moving on — recall is what makes it stick.