Skip to content

Java Generics — Complete Guide with Examples

DodaTech Updated 2026-06-20 11 min read

In this tutorial, you'll learn about Java Generics. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Java Generics let classes and methods operate on types specified by the caller, providing compile-time type safety without runtime overhead through type erasure.

What You'll Learn

You'll understand why generics exist, how to write generic classes and methods, use bounded type parameters and wildcards, navigate the subtleties of type erasure, and apply generics in real-world patterns like type-safe collections and data processing pipelines.

Why Generics Matter

Before generics, collections held Object references, requiring manual casting that could fail at runtime with ClassCastException. Generics shift these errors to compile time, catching type mismatches before deployment. DodaTech's data processing pipeline uses generics extensively — a Result<T> type that carries both data and metadata, and generic Repository methods that work with any entity type without casting.

Real-World Use: Type-Safe Configuration Registry

Imagine a configuration system where different settings have different types: timeout: int, hostname: String, enabled: boolean. Without generics, you'd store everything as Object and cast on retrieval. With generics, you write ConfigRegistry.get("timeout", Integer.class) once and it's type-safe forever. DodaTech uses this pattern in its device management platform.

Generics Learning Path

flowchart LR
  A[Java OOP] --> B[Java Collections]
  B --> C[Java Generics]
  C --> D[Generic Classes]
  C --> E[Generic Methods]
  C --> F[Bounded Type Parameters]
  C --> G[Wildcards]
  D --> H[Java Lambdas & Streams]
  E --> I[Custom Data Structures]
  C:::current

  classDef current fill:#3b82f6,color:#fff,stroke:#333,stroke-width:2px

Why Generics? The Problem They Solve

import java.util.*;

public class WithoutGenerics {
    public static void main(String[] args) {
        // Pre-generics style — everything is Object
        List devices = new ArrayList();
        devices.add("Router-01");
        devices.add("Switch-02");
        devices.add(12345);  // Oops — integer mixed in!

        for (Object obj : devices) {
            // Cast — will fail at runtime on the integer
            String device = (String) obj;
            System.out.println("Device: " + device);
        }
    }
}

Expected output:

Device: Router-01
Device: Switch-02
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String

With generics, this error is caught at compile time:

public class WithGenerics {
    public static void main(String[] args) {
        List<String> devices = new ArrayList<>();
        devices.add("Router-01");
        devices.add("Switch-02");
        // devices.add(12345);  // COMPILE ERROR — type safe!

        for (String device : devices) {  // No cast needed
            System.out.println("Device: " + device);
        }
    }
}

Expected output:

Device: Router-01
Device: Switch-02

The List<String> declaration tells the compiler: "this list can only hold strings." The compiler enforces this, eliminating an entire class of runtime errors.

Generic Classes

A generic class uses a type parameter in angle brackets. Think of T as a placeholder for the actual type that gets filled in when you create an instance.

// Generic container — stores a value and its timestamp
public class TimestampedValue<T> {
    private final T value;
    private final long timestamp;

    public TimestampedValue(T value) {
        this.value = value;
        this.timestamp = System.currentTimeMillis();
    }

    public T getValue() {
        return value;
    }

    public long getTimestamp() {
        return timestamp;
    }

    public boolean isExpired(long ttlMillis) {
        return System.currentTimeMillis() - timestamp > ttlMillis;
    }
}

public class GenericClassDemo {
    public static void main(String[] args) throws InterruptedException {
        // T is replaced with String
        TimestampedValue<String> configValue =
            new TimestampedValue<>("192.168.1.1");
        System.out.println("Config: " + configValue.getValue());

        // T is replaced with Integer — completely type-safe
        TimestampedValue<Integer> portValue =
            new TimestampedValue<>(8080);
        System.out.println("Port: " + portValue.getValue());

        // Check expiry
        Thread.sleep(100);
        System.out.println("Expired? " + configValue.isExpired(50));
    }
}

Expected output:

Config: 192.168.1.1
Port: 8080
Expired? true

The same TimestampedValue class works with String, Integer, or any other reference type. The compiler generates appropriate bytecode for each usage through type erasure.

Generic Methods

Sometimes only a method needs to be generic, not the entire class. Generic methods declare their type parameters before the return type.

import java.util.*;

public class GenericMethodDemo {
    public static void main(String[] args) {
        String[] deviceNames = {"Router-A", "Switch-B", "Firewall-C"};
        Integer[] portNumbers = {22, 80, 443, 8080};

        // Call generic method with different types
        String firstDevice = firstElement(deviceNames);
        Integer firstPort = firstElement(portNumbers);

        System.out.println("First device: " + firstDevice);
        System.out.println("First port: " + firstPort);

        // Type-safe array-to-list conversion
        List<String> deviceList = arrayToList(deviceNames);
        System.out.println("Devices: " + deviceList);
    }

    // Generic method — <T> before return type
    public static <T> T firstElement(T[] array) {
        if (array == null || array.length == 0) {
            return null;
        }
        return array[0];
    }

    // Another generic method with type inference
    public static <T> List<T> arrayToList(T[] array) {
        List<T> list = new ArrayList<>();
        for (T element : array) {
            list.add(element);
        }
        return list;
    }
}

Expected output:

First device: Router-A
First port: 22
Devices: [Router-A, Switch-B, Firewall-C]

The <T> before the return type declares the type parameter. The compiler infers T from the argument type — no explicit type passing is needed.

Bounded Type Parameters

Bounded type parameters restrict what types can be used as arguments. Use extends to specify an upper bound.

import java.util.*;

public class BoundedTypeDemo {
    public static void main(String[] args) {
        List<Integer> signalLevels = Arrays.asList(-50, -65, -80, -95);
        List<Double> temperatures = Arrays.asList(36.5, 37.0, 38.2);

        // Works with Integer (Number subclass)
        System.out.println("Avg signal: " + average(signalLevels));

        // Works with Double (Number subclass)
        System.out.println("Avg temp: " + average(temperatures));

        // Would NOT compile — String is not a Number
        // List<String> names = Arrays.asList("a", "b");
        // System.out.println(average(names));
    }

    // T must be a Number or subclass — bounded by 'extends Number'
    public static <T extends Number> double average(List<T> numbers) {
        double sum = 0.0;
        for (T num : numbers) {
            sum += num.doubleValue();
        }
        return sum / numbers.size();
    }
}

Expected output:

Avg signal: -72.5
Avg temp: 37.233333333333334

<T extends Number> ensures only Number and its subclasses (Integer, Double, Long, etc.) can be used. The compiler rejects String or other unrelated types.

Wildcards — ? for Unknown Types

Wildcards handle cases where the exact type is unknown. Use ? extends T for producers (read) and ? super T for consumers (write) — this is the PECS rule (Producer-Extends, Consumer-Super).

import java.util.*;

public class WildcardDemo {
    public static void main(String[] args) {
        List<Integer> intList = Arrays.asList(1, 2, 3);
        List<Double> doubleList = Arrays.asList(1.5, 2.5, 3.5);
        List<Object> objectList = new ArrayList<>();

        // Upper-bounded wildcard — can read Integers or Doubles
        System.out.println("Sum of ints: " + sumOfNumberList(intList));
        System.out.println("Sum of doubles: " + sumOfNumberList(doubleList));

        // Lower-bounded wildcard — can write Integers into Object list
        addAll(intList, objectList);
        System.out.println("Object list: " + objectList);
    }

    // Upper-bounded wildcard — read any Number subclass
    public static double sumOfNumberList(List<? extends Number> list) {
        double sum = 0;
        for (Number n : list) {
            sum += n.doubleValue();
        }
        return sum;
    }

    // Lower-bounded wildcard — write Integers to any Integer supertype
    public static void addAll(List<Integer> source, List<? super Integer> dest) {
        for (Integer item : source) {
            dest.add(item);
        }
    }

    // Unbounded wildcard — any type, read-only
    public static void printList(List<?> list) {
        for (Object item : list) {
            System.out.println(item);
        }
    }
}

Expected output:

Sum of ints: 6.0
Sum of doubles: 7.5
Object list: [1, 2, 3]

PECS rule explained: If a collection provides data to your code (producer), use ? extends T. If your code provides data to a collection (consumer), use ? super T. If both, don't use a wildcard.

Type Erasure — What Happens at Runtime

Java Generics are a compile-time feature. The compiler removes (erases) generic type information and inserts casts where needed. This preserves backward compatibility with pre-generics code.

import java.util.*;

public class TypeErasureDemo {
    public static void main(String[] args) {
        List<String> strings = new ArrayList<>();
        List<Integer> integers = new ArrayList<>();

        // At runtime, both are just ArrayList — no generic info
        System.out.println("Same class? " +
            strings.getClass().equals(integers.getClass()));

        // Reflection confirms — generic types are erased
        System.out.println("String list type: " +
            strings.getClass().getName());
    }
}

Expected output:

Same class? true
String list type: java.util.ArrayList

What type erasure means:

  • You cannot use instanceof with generic types: if (obj instanceof List<String>) — compile error
  • You cannot create generic arrays: new T[10] — compile error
  • Static fields are shared across all type instantiations
  • Bridge methods are generated to maintain polymorphism

What Is the PECS Rule in Java Generics?

PECS stands for "Producer-Extends, Consumer-Super." When a collection produces elements you read, use ? extends T. When a collection consumes elements you write, use ? super T. If you do both reading and writing, use an exact type T.

Common Mistakes Beginners Make

  1. Using raw types: List instead of List<String> turns off generic type safety and causes unchecked warnings. The compiler can't help you catch type errors.

  2. Cannot create arrays of parameterized types: new List<String>[10] doesn't compile because type erasure makes runtime array store checks impossible. Use ArrayList<List<String>> instead.

  3. Cannot use instanceof with generic types: if (obj instanceof List<String>) is illegal. Use if (obj instanceof List<?>) and cast with a warning suppression.

  4. Confusing ? extends T and ? super T: Using the wrong wildcard direction causes compilation errors. Remember PECS: Producer Extends, Consumer Super.

  5. Over-complicating with multiple bounds: <T extends A & B & C> works but is hard to read and maintain. Consider interfaces and composition instead of complex bounds.

  6. Generic static methods confusion: Static methods cannot use the class-level type parameter. They need their own <T> declaration before the return type.

  7. Forgetting that primitives don't work: List<int> doesn't compile. Use List<Integer> with autoboxing. Java's automatic conversion between int and Integer handles this transparently.

Practice Questions

  1. What is the difference between List<?> and List<Object>?
  2. Why does new E[10] not compile in a generic class?
  3. What does type erasure do?
  4. When would you use ? super T instead of ? extends T?
  5. Can a generic method have multiple type parameters?

Answers:

  1. List<?> is a list of an unknown type — you can only read from it (as Object). List<Object> is a list specifically typed to hold Object and its subclasses — you can both read and write.
  2. Because type erasure removes the generic type information at runtime, the JVM cannot perform array store checks. The compiler prevents this to avoid heap pollution.
  3. Type erasure removes generic type parameters after compilation, replacing them with their bounds (or Object if unbounded). It inserts casts where needed. This preserves backward compatibility with pre-generics bytecode.
  4. Use ? super T when you're writing elements into a collection (consumer). For example, adding Integer values to a List<? super Integer> that could be List<Integer>, List<Number>, or List<Object>.
  5. Yes, separate them with commas: <T, U, V>. For example, a method that takes Map<T, U> and returns List<V> needs three type parameters.

Challenge

Build a generic Cache<K, V> class with time-based expiration using TimestampedValue<V>. Support get(K key), put(K key, V value, long ttlMs), and cleanup() that removes expired entries. Use bounded wildcards in a method that copies entries from one cache to another.

Real-World Task

Create a generic Result<T> class that wraps a value or an error, similar to Rust's Result type. Include map(), flatMap(), and orElse() methods. Then build a pipeline of data processing steps — each step returns Result<T> — and chain them together. This is the pattern DodaTech uses for fault-tolerant data processing in its analytics pipeline.

FAQ

What is the difference between bounded and unbounded wildcards?

: An unbounded wildcard (?) accepts any type. An upper-bounded wildcard (? extends T) accepts T or any subtype. A lower-bounded wildcard (? super T) accepts T or any supertype. Bounded wildcards restrict what you can do with the collection but give you more type information.

Why can't I create a generic array?

: Arrays are covariant and reified (they know their element type at runtime). Generics are invariant and erased. These two systems don't work together — creating new List<String>[10] would allow heap pollution. Use ArrayList or @SuppressWarnings("unchecked") with (T[]) new Object[10] instead.

Can I use generics with primitive types?

: No — generics only work with reference types. Use wrapper classes (Integer, Double, Boolean) instead. Java's autoboxing automatically converts between primitives and their wrappers, making this mostly transparent in practice.

What is a wildcard capture?

: Wildcard capture is when the compiler infers a concrete type for a wildcard. For example, in void swap(List<?> list, int i, int j), the compiler can't directly write list.set(i, list.get(j)) because ? is unknown. A helper method <T> void swapHelper(List<T> list, int i, int j) captures the wildcard as T, allowing the swap.

What's Next

Java Lambdas & Functional Programming
Java Streams Guide
Java Collections Framework
Spring Framework Guide

Related topics: Java, Java Collections, Java OOP, Functional Programming, Spring

Generics are one of those topics that seem confusing at first but become invisible once you understand them. Write one generic class and one generic method today — your future self will thank you for the reusable, type-safe code.

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro