Java Generics

Reviewed & published by Brayan K

After this lesson you'll write type-safe, reusable containers and methods — the same machinery that powers Java's collections — and you'll know exactly when to reach for a wildcard.

Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

📚 Before You Start

You should already be comfortable with:

Real-World Analogy

Think of a generic class as a labelled shipping container. The container itself is the same design no matter what goes inside — but when you fill it, you slap a label on the door: "Books only" or "Glassware only."

The label is the type argument. A container labelled Box<Glass> will refuse a crate of books at the loading dock — the compiler is the dock worker checking the label. Without a label (a "raw type"), anything goes in, and you only discover the smashed glass when you open it later (a runtime crash). Generics move that check from opening the box (runtime) to loading the box (compile time).

1️⃣ Why Generics? The Problem

Before generics, collections held Object, so they accepted anything — and you had to cast on the way out. One wrong assumption and your program crashed at runtime.

// WITHOUT generics — dangerous!
List list = new ArrayList();      // a "raw type" — no label
list.add("Hello");
list.add(42);                     // no error — anything goes in
String s = (String) list.get(1);  // RUNTIME CRASH: ClassCastException!

// WITH generics — the compiler protects you
List<String> list = new ArrayList<>();
list.add("Hello");
// list.add(42);                  // COMPILE ERROR — caught immediately!
String s = list.get(0);           // no cast needed

Key benefit: a bug caught at compile time is far cheaper than one your users find at runtime.

2️⃣ Generic Classes & Multiple Type Parameters

A generic class declares one or more type parameters in angle brackets after its name. Inside the class, those parameters act like real types; they get filled in when you create an instance.

public class Box<T> {              // T is a placeholder for "some type"
    private T content;
    public void set(T item) { content = item; }
    public T get() { return content; }
}

Box<String> nameBox = new Box<>(); // T becomes String here
nameBox.set("Alice");
String name = nameBox.get();        // no casting needed!

// TWO type parameters — a key and a value
public class Pair<K, V> {
    private final K key;
    private final V value;
    public Pair(K key, V value) { this.key = key; this.value = value; }
}

Pair<String, Integer> entry = new Pair<>("Alice", 95);

Naming convention: T = Type, E = Element, K = Key, V = Value, N = Number. The <> on the right is the "diamond" — Java infers the type, so you don't repeat it.

import java.util.Arrays;

public class Main {
    // 1. Generic class — T is a placeholder, filled in when you create a Box.
    static class Box<T> {
        private T content;
        void set(T item) { content = item; }   // accepts only a T
        T get() { return content; }             // returns a T — no cast needed
        @Override public String toString() { return "Box[" + content + "]"; }
    }

    // 2. Two type parameters: K (key) and V (value).
    static class Pair<K, V> {
        final K key;
        final V value;
        Pair(K key, V value) { this.key = key; this.value = value; }
        @Override public String toString() { return "(" + key + " -> " + value + ")"; }
    }

    public static void main(String[] args) {
        // A Box<String> can ONLY hold Strings.
        Box<String> stringBox = new Box<>();    // <> is the "diamond" — Java infers String
        stringBox.set("Hello Generics!");
        String text = stringBox.get();          // no (String) cast required
        System.out.println("String box: " + text);

        // A Box<Integer> can ONLY hold Integers.
        Box<Integer> numberBox = new Box<>();
        numberBox.set(42);
        int n = numberBox.get();                // unboxes straight to int
        System.out.println("Number box: " + n);

        // Pair<K, V> bundles two values of (possibly) different types.
        Pair<String, Integer> alice = new Pair<>("Alice", 95);
        Pair<String, Integer> bob = new Pair<>("Bob", 87);
        System.out.println("Pair 1: " + alice);
        System.out.println("Pair 2: " + bob);

        // Generics make collections type-safe too.
        Pair<String, Integer>[] scores = new Pair[]{ alice, bob };
        System.out.println("All pairs: " + Arrays.toString(scores));
    }
}

3️⃣ Generic Methods

A method can have its own type parameter, even inside a non-generic class. You declare it in angle brackets just before the return type. Java infers the actual type from the arguments you pass.

//        the <T> here declares the parameter, before the return type
public static <T> void printArray(T[] array) {
    for (T item : array) System.out.print(item + " ");
}

printArray(new String[]{"a", "b", "c"});  // T inferred as String -> a b c
printArray(new Integer[]{1, 2, 3});       // T inferred as Integer -> 1 2 3

// Returns a T — the caller gets back exactly the type they passed in
public static <T> T pick(T a, T b) {
    return a != null ? a : b;
}

4️⃣ Bounded Type Parameters

<T extends X> means "T must be X, or a subclass/implementer of X." This unlocks X's methods on T. (Note: for both classes and interfaces you write extends, never implements.)

// Only accepts Number or its subclasses (Integer, Double, ...)
public class NumberBox<T extends Number> {
    private T value;
    public NumberBox(T value) { this.value = value; }
    public double doubleValue() { return value.doubleValue(); } // allowed!
}

NumberBox<Integer> intBox = new NumberBox<>(42);   // OK
// NumberBox<String> bad = ...;  // COMPILE ERROR — String isn't a Number

// Because T is Comparable, we may call compareTo inside the method
public static <T extends Comparable<T>> T findMax(T[] array) {
    T max = array[0];
    for (T item : array) if (item.compareTo(max) > 0) max = item;
    return max;
}
import java.util.List;

public class Main {
    // Generic METHOD — the <T> before the return type declares the parameter.
    // Works with an array of ANY type.
    static <T> void printArray(T[] array, String label) {
        StringBuilder sb = new StringBuilder(label + ": [");
        for (int i = 0; i < array.length; i++) {
            sb.append(array[i]);
            if (i < array.length - 1) sb.append(", ");
        }
        System.out.println(sb.append("]"));
    }

    // <T> T pick — returns whichever argument is non-null.
    static <T> T pick(T a, T b) {
        return a != null ? a : b;
    }

    // BOUNDED: T must be Comparable, so the compiler lets us call compareTo.
    static <T extends Comparable<T>> T findMax(T[] array) {
        T max = array[0];
        for (T item : array) if (item.compareTo(max) > 0) max = item;
        return max;
    }

    // PECS — Producer Extends: we only READ Numbers out of the list.
    static double sum(List<? extends Number> numbers) {
        double total = 0;
        for (Number x : numbers) total += x.doubleValue();
        return total;
    }

    public static void main(String[] args) {
        printArray(new Integer[]{1, 2, 3, 4, 5}, "Integers");
        printArray(new String[]{"Alice", "Bob", "Charlie"}, "Strings");

        System.out.println("pick(null, 7) = " + pick(null, 7));
        System.out.println("max of [5,2,8,1,9] = " + findMax(new Integer[]{5, 2, 8, 1, 9}));
        System.out.println("max of [c,a,z,b]   = " + findMax(new String[]{"c", "a", "z", "b"}));

        System.out.println("sum([1, 2, 3])       = " + sum(List.of(1, 2, 3)));
        System.out.println("sum([1.5, 2.5, 3.5]) = " + sum(List.of(1.5, 2.5, 3.5)));
    }
}

🎯 Your Turn #1 — A Generic Holder

Fill in the two blanks so Holder becomes a generic class that stores and returns a value of any type. Declare the type parameter, then give get() the right return type.

public class Main {
    // 🎯 YOUR TURN — fill in the blanks marked with ___

    // A simple generic container that remembers ONE value of any type.
    static class Holder<___> {        // 👉 declare a type parameter named T
        private T value;
        void set(T v) { value = v; }
        ___ get() { return value; }   // 👉 this method returns a T
    }

    public static void main(String[] args) {
        Holder<String> name = new Holder<>();
        name.set("Ada");
        Holder<Integer> year = new Holder<>();
        year.set(1815);

        System.out.println("Name: " + name.get());
        System.out.println("Year: " + year.get());

        // ✅ Expected output:
        // Name: Ada
        // Year: 1815
    }
}

🎯 Your Turn #2 — A Bounded min() Method

Complete the generic min method. It needs a Comparable bound so it can call compareTo, and the right comparison operator to pick the smaller value.

public class Main {
    // 🎯 YOUR TURN — fill in the blanks marked with ___

    // A generic method that returns the SMALLER of two values.
    // It needs a Comparable bound so it can call compareTo.
    static <T extends ___<T>> T min(T a, T b) {   // 👉 the bound interface (think "compare")
        return a.compareTo(b) ___ 0 ? a : b;       // 👉 operator: negative means a is smaller
    }

    public static void main(String[] args) {
        System.out.println("min(3, 9)        = " + min(3, 9));
        System.out.println("min(apple, kiwi) = " + min("apple", "kiwi"));

        // ✅ Expected output:
        // min(3, 9)        = 3
        // min(apple, kiwi) = apple
    }
}

5️⃣ Wildcards & PECS

A wildcard ? means "some specific but unknown type." It lets a method accept a whole family of generic types instead of one exact type.

PECS Rule: Producer Extends, Consumer Super. If the parameter produces values you read, use extends. If it consumes values you write, use super.

// PRODUCER — the list produces Numbers we read (extends)
public double sum(List<? extends Number> numbers) {
    double total = 0;
    for (Number n : numbers) total += n.doubleValue();
    return total;
}

// CONSUMER — the list consumes Integers we write (super)
public void addIntegers(List<? super Integer> list) {
    list.add(1);
    list.add(2);
}

6️⃣ Type Erasure — Under the Hood (and Its Limits)

Java generics are a compile-time feature only. After the compiler checks your types, it erases them — replacing each type parameter with its bound (or Object if unbounded) and inserting casts. At runtime the JVM has no idea what T was.

This is why List<String> and List<Integer> are literally the same class when the program runs. Erasure keeps generics backwards-compatible with pre-2004 Java, but it imposes real limits:

Workarounds: pass a Class<T> token to create instances, and prefer an ArrayList<T> over a raw generic array.

import java.util.ArrayList;
import java.util.List;

public class Main {
    // A type-safe generic stack — works for any element type T.
    static class Stack<T> {
        private final List<T> items = new ArrayList<>();
        void push(T item) { items.add(item); }
        T pop() {
            if (isEmpty()) throw new RuntimeException("Stack underflow!");
            return items.remove(items.size() - 1);   // returns a T, no cast
        }
        T peek() {
            if (isEmpty()) throw new RuntimeException("Stack is empty!");
            return items.get(items.size() - 1);
        }
        boolean isEmpty() { return items.isEmpty(); }
        @Override public String toString() { return items.toString(); }
    }

    // Reuses Stack<Character> to validate balanced brackets.
    static boolean checkBrackets(String str) {
        Stack<Character> stack = new Stack<>();
        for (char ch : str.toCharArray()) {
            if (ch == '(' || ch == '[' || ch == '{') stack.push(ch);
            else if (ch == ')' || ch == ']' || ch == '}') {
                if (stack.isEmpty()) return false;
                char open = stack.pop();
                if ((ch == ')' && open != '(') ||
                    (ch == ']' && open != '[') ||
                    (ch == '}' && open != '{')) return false;
            }
        }
        return stack.isEmpty();
    }

    public static void main(String[] args) {
        // Same Stack class, a String element type.
        Stack<String> undo = new Stack<>();
        undo.push("Type Hello");
        undo.push("Bold text");
        undo.push("Add image");
        System.out.println("Stack: " + undo);
        System.out.println("Undo:  " + undo.pop());
        System.out.println("Top:   " + undo.peek());

        String[] tests = { "(a + b) * [c]", "((a + b)", "{[()]}", "([)]" };
        for (String t : tests) {
            System.out.println("'" + t + "' -> " + (checkBrackets(t) ? "valid" : "invalid"));
        }

        // The compiler stops you mixing types: undo.push(42) would NOT compile.
        try {
            new Stack<Integer>().pop();
        } catch (RuntimeException e) {
            System.out.println("Caught: " + e.getMessage());
        }
    }
}

🧩 Mini-Challenge — Type-Safe Pair Printer

Time to write one from scratch. Read the brief in the comments, then fill in the body yourself — no blanks to lean on this time.

public class Main {
    // 🎯 MINI-CHALLENGE: a type-safe Pair printer
    // 1. Write a generic method:  static <K, V> void printPair(K key, V value)
    //    that prints:  key => value
    // 2. Call it with ("score", 95) and ("active", true)
    //
    // ✅ Expected output:
    // score => 95
    // active => true

    public static void main(String[] args) {
        // your code here
    }
}

Common Errors (and the Fix)

❌ Raw types — "unchecked call to add(E)"

Writing List list = new ArrayList(); drops all type safety and triggers an "unchecked" warning. Always parameterise:

List list = new ArrayList();           // ❌ raw — anything goes in
List<String> list = new ArrayList<>(); // ✅ type-safe

❌ "cannot instantiate the type T" — new T()

Type erasure means there's no T at runtime. Pass a factory or a class token instead:

T value = new T();                       // ❌ won't compile
T value = clazz.getDeclaredConstructor()
              .newInstance();            // ✅ clazz is a Class<T> you passed in

❌ "generic array creation" — new T[10]

You can't create an array of a type parameter. Use a collection, or create an Object[] and cast (with a warning):

T[] arr = new T[10];                     // ❌ generic array creation
List<T> arr = new ArrayList<>();         // ✅ preferred
@SuppressWarnings("unchecked")
T[] arr = (T[]) new Object[10];          // ✅ works, but cast is unchecked

❌ "incompatible types" — adding to a ? extends list

This is wildcard-capture confusion. You can't add to a ? extends list because the exact subtype is unknown. Read from extends; write to super:

void f(List<? extends Number> nums) {
    nums.add(1);   // ❌ might be a List<Double> — 1 wouldn't fit
    Number n = nums.get(0); // ✅ reading is always safe
}
void g(List<? super Integer> sink) {
    sink.add(1);   // ✅ Integer always fits a List<Integer-or-supertype>
}

📋 Quick Reference

SyntaxExampleMeaning
<T>class Box<T>Type parameter
<K, V>Pair<String, Integer>Multiple type params
<T> T m(...)<T> T pick(T a, T b)Generic method
<T extends X><T extends Comparable<T>>Upper bound — unlocks X's methods
<?>List<?>Unknown type (read-only)
<? extends T>List<? extends Number>Producer — read values
<? super T>List<? super Integer>Consumer — write values

🎉 Lesson Complete!

You can now write generic classes (Box<T>, Pair<K, V>) and generic methods (<T> T pick(...)), constrain them with bounds, choose wildcards using PECS, and explain why type erasure forbids new T() and generic arrays. This is the exact toolkit behind Java's collections and APIs.

Practice quiz

In 'class Box<T>', what is T?

  • A reserved keyword
  • A primitive type
  • A type parameter (placeholder for some type)
  • A variable name

Answer: A type parameter (placeholder for some type). T is a type parameter, filled in when you create a Box like Box<String>.

Where do you declare the type parameter of a generic METHOD?

  • Just before the return type
  • After the method name
  • Inside the parameter list
  • On the class only

Answer: Just before the return type. A generic method declares <T> just before its return type, e.g. static <T> T pick(...).

By convention, which letter is used for the type parameter of a key?

  • T
  • E
  • N
  • K

Answer: K. Convention: T=Type, E=Element, K=Key, V=Value, N=Number.

What does '<T extends Comparable<T>>' allow you to do inside the method?

  • Create new T objects
  • Call compareTo on a T
  • Make a T array
  • Cast T to int

Answer: Call compareTo on a T. The bound guarantees every T implements Comparable, so compareTo is allowed.

Per PECS, which wildcard do you use for a list you only READ from?

  • ? extends T
  • ? super T
  • raw type
  • ? equals T

Answer: ? extends T. Producer Extends: use ? extends T when the list produces values you read.

Why can't you write 'new T()' inside a generic class?

  • It is too slow
  • T must be final
  • Type erasure removes T at runtime
  • It needs an import

Answer: Type erasure removes T at runtime. Type erasure means there is no T at runtime, so the JVM cannot instantiate it.

At runtime, List<String> and List<Integer> are...

  • Completely different classes
  • The same class (erasure)
  • Subclasses of each other
  • Not real classes

Answer: The same class (erasure). After type erasure they are literally the same class at runtime.

Can you add elements (other than null) to a List<? extends Number>?

  • Yes, any Number
  • Only Integers
  • Only if it is empty
  • No — the exact subtype is unknown

Answer: No — the exact subtype is unknown. You cannot add to a ? extends list because the exact subtype is unknown; you can only read.

Do generics make a program run faster or use less memory?

  • Yes, much faster
  • No — they are a compile-time-only feature
  • Only with bounds
  • Only for collections

Answer: No — they are a compile-time-only feature. Generics are purely compile-time; after erasure runtime cost is unchanged.

For a type parameter bounded by an interface, which keyword do you use?

  • implements
  • super
  • extends
  • with

Answer: extends. For both classes and interfaces in a bound you write 'extends', e.g. <T extends Comparable<T>>.

Continue this course

Frequently asked questions

What is the difference between a generic class and a generic method?

A generic class declares its type parameter on the class itself, e.g. class Box<T>, and every instance is locked to one type such as Box<String>. A generic method declares its own type parameter just before the return type, e.g. static <T> T pick(T a, T b), so the type is chosen fresh on each call and the method can live in a non-generic class.

What does <T extends Comparable<T>> actually mean?

It is a bounded type parameter. It says T can be any type, but only one that implements Comparable<T>. Because the compiler now knows every T has a compareTo method, you are allowed to call item.compareTo(other) inside the method. Without the bound the compiler rejects that call, since a plain T has no such method.

When do I use ? extends versus ? super (PECS)?

Remember PECS: Producer Extends, Consumer Super. Use List<? extends Number> when the list produces values you only read out (you can read, but cannot add). Use List<? super Integer> when the list consumes values you write in (you can add Integers, but reads come back as Object). If you both read and write, use a normal named type parameter instead of a wildcard.

Why can't I write new T() or new T[10] inside a generic class?

Because of type erasure: the compiler removes generic type information after checking it, so at runtime there is no T to instantiate. To create instances, pass a Class<T> token and call clazz.getDeclaredConstructor().newInstance(), or accept a factory/Supplier. For arrays, create an Object[] and cast, or better, use an ArrayList<T> instead of a raw generic array.

Do generics make my program run faster or use less memory?

No. Generics are purely a compile-time feature. After erasure, List<String> and List<Integer> are the exact same class at runtime. Generics give you compile-time type safety and remove manual casts, which means fewer bugs and cleaner code — but the bytecode and runtime cost are the same as the old raw-type version.