Skip to content

How to Write a Generic Class and Method in Java (Java 21)

Write a generic class, generic methods, bounded types and wildcards in Java 21 by building a parcel shelf, with 21 passing checks and the compiler errors.

how-to-tutorials core-java java-tutorials
Gopi Gorantala
Reading Progress

On This Page

A shelf that rejects the wrong parcel at compile time

Here's the failure this article exists to remove. A shelf that stores Object accepts a String next to a Parcel, and nothing complains until somebody reads it back:

raw shelf, at runtime: class java.lang.String cannot be cast to class dev.ggorantala.parcels.Parcel (java.lang.String is in module java.base of loader 'bootstrap'; dev.ggorantala.parcels.Parcel is in unnamed module of loader 'app')

By the end you'll know how to write a generic class and method in Java so that the same mistake stops at the compiler. You'll build a small parcel-tracker library: a Shelf<T>, a two-parameter Pair<A, B>, a Page<T>, a Repository<T, ID>, and a Generics class with a bounded maxOf, a copy that uses wildcards correctly, and a groupBy. Then you'll see what erasure leaves behind at runtime, because that's where generics bite people.

This is for you if you can read List<String> but freeze when you have to write <T extends Comparable<? super T>>. I'll explain that signature piece by piece, and I'll show every compiler error I forced so you recognise them later.

Demo / Checks
   |
   +-- Shelf<T>, Pair<A,B>, Page<T>     generic classes and records
   +-- Repository<T,ID> -> InMemoryRepository<T,ID>   generic interface + implementation
   +-- Generics.maxOf / copy / groupBy / firstOfType  generic static methods

Tools and versions

Tool Version I used Check it with
JDK OpenJDK 21.0.12.1 (Ubuntu build) java -version
javac 21.0.12.1 javac -version
Gradle (optional) 8.14.3, Kotlin DSL gradle -v

Any JDK 21 works; Temurin is fine. Generics themselves have been in Java since 5, but the files below use records, so Java 16 or newer is the floor. I ran everything on the command line. I did not run IntelliJ IDEA or Eclipse for this article, so the IDE steps below come from how those IDEs normally work and I've kept them short. I also didn't run Maven; the Gradle build is the one I executed. No JUnit either: the Maven Central host wasn't reachable from my build environment, so the checks are a plain main method that prints PASS or FAIL. You can move them into JUnit 5 later without changing a line of the library.

Create the parcel-shelf project

Use this tree. Package dev.ggorantala.parcels in every file.

parcel-shelf/
├── build.gradle.kts            (optional)
├── settings.gradle.kts         (optional)
└── src/
    ├── main/java/dev/ggorantala/parcels/
    │   ├── Status.java
    │   ├── Parcel.java
    │   ├── RawShelf.java
    │   ├── Shelf.java
    │   ├── Pair.java
    │   ├── Page.java
    │   ├── Repository.java
    │   ├── InMemoryRepository.java
    │   ├── Generics.java
    │   ├── Demo.java
    │   └── Traps.java
    └── test/java/dev/ggorantala/parcels/Checks.java

Command line. Create the folders, drop the files in, then:

javac -Xlint:all -d out $(find src -name '*.java')
java -cp out dev.ggorantala.parcels.Demo

On Windows PowerShell, use javac -Xlint:all -d out (Get-ChildItem -Recurse -Filter *.java src).FullName instead of the $(find ...) part.

IntelliJ IDEA. File → New → Project, pick Java, set the JDK to 21, and create the packages under src/main/java. Not executed here. Eclipse. File → New → Java Project, set the execution environment to JavaSE-21, and add the same source folders. Not executed here either.

Gradle (optional, and the one I ran). Put these two files in the project root:

settings.gradle.kts

rootProject.name = "parcel-shelf"

build.gradle.kts

plugins {
    application
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

sourceSets {
    create("checks") {
        java.srcDir("src/test/java")
        compileClasspath += sourceSets.main.get().output
        runtimeClasspath += sourceSets.main.get().output
    }
}

application {
    mainClass = "dev.ggorantala.parcels.Demo"
}

tasks.register<JavaExec>("checks") {
    classpath = sourceSets["checks"].runtimeClasspath
    mainClass = "dev.ggorantala.parcels.Checks"
}

gradle run starts the demo and gradle checks runs the checks. The checks source set exists only so the check class can live under src/test/java without a test framework.

Write the code: class, method, bound, wildcard

The whole job is about one question: what does the compiler need to know so it can stop a wrong call? Each step gives it a bit more.

Step 1: Create the domain types and a shelf that does not know any better

Goal: have a parcel and the broken baseline to compare against.

src/main/java/dev/ggorantala/parcels/Status.java

package dev.ggorantala.parcels;

public enum Status { REGISTERED, IN_TRANSIT, DELIVERED }

src/main/java/dev/ggorantala/parcels/Parcel.java

package dev.ggorantala.parcels;

public record Parcel(String id, String city, int weightGrams, Status status)
        implements Comparable<Parcel> {

    @Override
    public int compareTo(Parcel other) {
        return Integer.compare(weightGrams, other.weightGrams);
    }
}

src/main/java/dev/ggorantala/parcels/RawShelf.java

package dev.ggorantala.parcels;

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

/** The "before" picture: no type parameter, everything is Object. */
@SuppressWarnings({"rawtypes", "unchecked"})
public class RawShelf {
    private final List items = new ArrayList();

    public void put(Object item) { items.add(item); }

    public Object get(int index) { return items.get(index); }
}

Parcel implements Comparable<Parcel> on purpose. Step 4 needs it, and it's the first generic type you've already been using without noticing. RawShelf is the pre-Java-5 style; I suppress the warnings so it compiles cleanly, because the point is what it does at runtime.

Checkpoint: javac -Xlint:all -d out $(find src -name '*.java') prints nothing once all files from the steps below are in place. Compile after each step if you like, but Demo needs them all.

Step 2: Turn the shelf into a generic class

Goal: put a type parameter on the class so the caller chooses what it holds.

src/main/java/dev/ggorantala/parcels/Shelf.java

package dev.ggorantala.parcels;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;

/** A generic class: T is whatever the caller says it is. */
public class Shelf<T> {
    private final List<T> items = new ArrayList<>();

    public void put(T item) { items.add(item); }

    public T get(int index) { return items.get(index); }

    public int size() { return items.size(); }

    public List<T> asList() { return List.copyOf(items); }

    /** A generic method inside a generic class: R is new, T is the class's. */
    public <R> Shelf<R> map(Function<? super T, ? extends R> fn) {
        Shelf<R> out = new Shelf<>();
        for (T item : items) out.put(fn.apply(item));
        return out;
    }
}

<T> after the class name declares a type parameter: a placeholder that the caller fills in with Shelf<Parcel>. Everywhere inside the class, T is an ordinary type. Two details worth a second look. asList() returns List.copyOf(items), so callers can't reach in and mutate the shelf. And map declares its own parameter <R> before the return type. That's a generic method: its type parameter belongs to the method, not the class, and the compiler infers R from the lambda you pass.

The parameter is Function<? super T, ? extends R> rather than Function<T, R>. That question mark is Step 5. For now, read it as "accept a function that is at least as general as asked for".

Try the compile error. With Shelf<Parcel> s, calling s.put("oops") stops here:

error: incompatible types: String cannot be converted to Parcel

That's the line the raw shelf could never produce.

Step 3: Add a two-parameter record and a generic page

Goal: show that records and static factories can be generic, and that map is the same idea again.

src/main/java/dev/ggorantala/parcels/Pair.java

package dev.ggorantala.parcels;

/** Two type parameters. A record can be generic too. */
public record Pair<A, B>(A first, B second) {

    public static <A, B> Pair<A, B> of(A first, B second) {
        return new Pair<>(first, second);
    }

    public Pair<B, A> swap() { return new Pair<>(second, first); }
}

src/main/java/dev/ggorantala/parcels/Page.java

package dev.ggorantala.parcels;

import java.util.List;
import java.util.function.Function;

public record Page<T>(List<T> content, int number, int totalPages) {

    public Page {
        content = List.copyOf(content);
    }

    public <R> Page<R> map(Function<? super T, ? extends R> fn) {
        return new Page<>(content.stream().<R>map(fn).toList(), number, totalPages);
    }
}

In Pair, the static of declares <A, B> again. A static method can't see the class's type parameters (there's no instance, so no A to refer to), which is why it needs its own. They happen to share the names; they're different variables. In Page, the compact constructor copies the list so the page can't be changed from outside. If records are new to you, How to use Java records covers the compact constructor.

The .<R>map(fn) with an explicit type witness is there because fn is declared Function<? super T, ? extends R> and I wanted toList() to give back exactly List<R>. Without it, javac fails with inference variable T has incompatible equality constraints R,CAP#1. Leave it in.

Step 4: Write a bounded generic method

Goal: a maxOf that works for parcels, strings, or anything comparable, and refuses everything else.

src/main/java/dev/ggorantala/parcels/Generics.java

package dev.ggorantala.parcels;

import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

/** Static generic methods: the type parameter sits before the return type. */
public final class Generics {
    private Generics() {}

    /** Bounded: T must be comparable with itself or a supertype of itself. */
    public static <T extends Comparable<? super T>> T maxOf(Collection<? extends T> items) {
        if (items.isEmpty()) throw new IllegalArgumentException("nothing to compare");
        T best = null;
        for (T item : items) {
            if (best == null || item.compareTo(best) > 0) best = item;
        }
        return best;
    }

    /** PECS: read from the producer (extends), write to the consumer (super). */
    public static <T> void copy(List<? super T> dest, List<? extends T> src) {
        for (T item : src) dest.add(item);
    }

    public static <T, K> Map<K, List<T>> groupBy(Collection<? extends T> items,
                                                 Function<? super T, ? extends K> key) {
        Map<K, List<T>> out = new LinkedHashMap<>();
        for (T item : items) {
            out.computeIfAbsent(key.apply(item), k -> new java.util.ArrayList<>()).add(item);
        }
        return out;
    }

    /** Read-only wildcard: works for List<Parcel>, List<Integer>, anything. */
    public static int countAll(Collection<?> items) { return items.size(); }

    /** Class token: the only way to keep a runtime type when T is erased. */
    public static <T> T firstOfType(Collection<?> items, Class<T> type) {
        for (Object o : items) {
            if (type.isInstance(o)) return type.cast(o);
        }
        return null;
    }
}

Read the maxOf signature left to right:

  • <T extends Comparable<? super T>>: T must have a natural ordering, either its own or one inherited from a supertype. The ? super T matters. A java.sql.Timestamp is comparable to Date, not to Timestamp, and a signature of plain Comparable<T> would reject it.
  • Collection<? extends T> items: I'll read from the collection, so any collection of T or a subtype is fine.
  • T returned: you get back exactly the type you can use, with no cast.

The bound is a compile-time promise. Pass something that isn't comparable and the compiler refuses. For a deeper look at ordering, see How to implement Comparable and Comparator in Java.

Step 5: Use wildcards the PECS way

Goal: copy into a more general list, groupBy with any key function.

Same file, Generics.java, already contains them. The rule I use: Producer Extends, Consumer Super. If a parameter gives you values (src), declare ? extends T. If it takes values (dest), declare ? super T. copy(List<? super T> dest, List<? extends T> src) therefore accepts a List<Parcel> going into a List<Object>, or a List<Integer> and a List<Double> going into one List<Number>. countAll(Collection<?>) is the "I only need the size" case: unbounded ?, and you can read objects out but not add anything.

Why not just List<Parcel> to List<Object>? Because List<Parcel> is not a List<Object>, even though Parcel is an Object. If it were, you could add a String through the List<Object> view and corrupt the original. Wildcards are how you say "any list of something compatible" safely.

Step 6: Add a generic interface and implementation

Goal: the shape every repository, handler, and mapper in your codebase will eventually have.

src/main/java/dev/ggorantala/parcels/Repository.java

package dev.ggorantala.parcels;

import java.util.List;
import java.util.Optional;

public interface Repository<T, ID> {
    void save(T entity);

    Optional<T> findById(ID id);

    List<T> findAll();
}

src/main/java/dev/ggorantala/parcels/InMemoryRepository.java

package dev.ggorantala.parcels;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;

public class InMemoryRepository<T, ID> implements Repository<T, ID> {
    private final Map<ID, T> store = new LinkedHashMap<>();
    private final Function<? super T, ? extends ID> idOf;

    public InMemoryRepository(Function<? super T, ? extends ID> idOf) {
        this.idOf = idOf;
    }

    @Override public void save(T entity) { store.put(idOf.apply(entity), entity); }

    @Override public Optional<T> findById(ID id) { return Optional.ofNullable(store.get(id)); }

    @Override public List<T> findAll() { return new ArrayList<>(store.values()); }
}

Two parameters keep the entity and its key separate: Repository<Parcel, String>. The constructor takes a Function that extracts the id, so the repository doesn't need Parcel to implement anything. That's a design call I'd make again: it keeps the generic class from knowing a thing about your entity.

Step 7: Wire it together in a demo

src/main/java/dev/ggorantala/parcels/Demo.java

package dev.ggorantala.parcels;

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

public class Demo {
    public static void main(String[] args) {
        RawShelf raw = new RawShelf();
        raw.put(new Parcel("P1", "Leeds", 800, Status.REGISTERED));
        raw.put("oops, a String");
        try {
            Parcel p = (Parcel) raw.get(1);
            System.out.println(p);
        } catch (ClassCastException e) {
            System.out.println("raw shelf, at runtime: " + e.getMessage());
        }

        Shelf<Parcel> shelf = new Shelf<>();
        shelf.put(new Parcel("P1", "Leeds", 800, Status.REGISTERED));
        shelf.put(new Parcel("P2", "Oslo", 2400, Status.IN_TRANSIT));
        shelf.put(new Parcel("P3", "Leeds", 150, Status.DELIVERED));
        // shelf.put("oops");   // does not compile
        System.out.println("shelf size: " + shelf.size());
        System.out.println("cities: " + shelf.map(Parcel::city).asList());

        System.out.println("heaviest: " + Generics.maxOf(shelf.asList()));
        System.out.println("last city alphabetically: " + Generics.maxOf(List.of("Leeds", "Oslo", "Rome")));

        Map<String, List<Parcel>> byCity = Generics.groupBy(shelf.asList(), Parcel::city);
        byCity.forEach((c, ps) -> System.out.println(c + " -> " + ps.stream().map(Parcel::id).toList()));

        List<Object> sink = new ArrayList<>();
        Generics.copy(sink, shelf.asList());
        System.out.println("sink holds " + sink.size() + " objects");

        Page<Parcel> page = new Page<>(shelf.asList(), 0, 1);
        Page<String> labels = page.map(p -> p.id() + "@" + p.city());
        System.out.println("labels: " + labels);

        Repository<Parcel, String> repo = new InMemoryRepository<>(Parcel::id);
        shelf.asList().forEach(repo::save);
        System.out.println("P2 -> " + repo.findById("P2").map(Parcel::city).orElse("?"));

        Pair<String, Integer> pair = Pair.of("P1", 800);
        System.out.println(pair + " swapped " + pair.swap());
    }
}

Checkpoint: compile with javac -Xlint:all -d out $(find src -name '*.java'). You should get no output at all, not even a warning.

Try it end to end

java -cp out dev.ggorantala.parcels.Demo
raw shelf, at runtime: class java.lang.String cannot be cast to class dev.ggorantala.parcels.Parcel (java.lang.String is in module java.base of loader 'bootstrap'; dev.ggorantala.parcels.Parcel is in unnamed module of loader 'app')
shelf size: 3
cities: [Leeds, Oslo, Leeds]
heaviest: Parcel[id=P2, city=Oslo, weightGrams=2400, status=IN_TRANSIT]
last city alphabetically: Rome
Leeds -> [P1, P3]
Oslo -> [P2]
sink holds 3 objects
labels: Page[content=[P1@Leeds, P2@Oslo, P3@Leeds], number=0, totalPages=1]
P2 -> Oslo
Pair[first=P1, second=800] swapped Pair[first=800, second=P1]

Two lines are worth a look. heaviest is a Parcel with no cast anywhere in Demo. And "last city alphabetically: Rome" is maxOf on strings (natural ordering is alphabetical). My first draft labelled that line "longest city", which was wrong. The output made it obvious, which is a good argument for running things.

On Gradle, gradle --offline run prints the same lines. The first run took about 32 seconds on my two-core machine, almost all of it the Gradle daemon starting up.

Run the 21 checks

src/test/java/dev/ggorantala/parcels/Checks.java

package dev.ggorantala.parcels;

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

public class Checks {
    static int passed, failed;

    static void check(String name, boolean ok) {
        if (ok) { passed++; System.out.println("PASS " + name); }
        else    { failed++; System.out.println("FAIL " + name); }
    }

    public static void main(String[] args) {
        Parcel a = new Parcel("A", "Leeds", 800, Status.REGISTERED);
        Parcel b = new Parcel("B", "Oslo", 2400, Status.IN_TRANSIT);
        Parcel c = new Parcel("C", "Leeds", 150, Status.DELIVERED);
        List<Parcel> all = List.of(a, b, c);

        Shelf<Parcel> shelf = new Shelf<>();
        all.forEach(shelf::put);
        check("shelf size", shelf.size() == 3);
        check("shelf get", shelf.get(1) == b);
        check("shelf asList is a copy", shelf.asList() != shelf.asList());
        check("shelf map changes type", shelf.map(Parcel::weightGrams).get(0) == 800);
        try { shelf.asList().add(a); check("asList immutable", false); }
        catch (UnsupportedOperationException e) { check("asList immutable", true); }

        check("maxOf parcels", Generics.maxOf(all) == b);
        check("maxOf strings", Generics.maxOf(List.of("a", "c", "b")).equals("c"));
        try { Generics.maxOf(List.<Integer>of()); check("maxOf empty throws", false); }
        catch (IllegalArgumentException e) { check("maxOf empty throws", true); }

        List<Object> sink = new ArrayList<>();
        Generics.copy(sink, all);
        check("copy into List<Object>", sink.size() == 3);
        List<Number> nums = new ArrayList<>();
        Generics.copy(nums, List.of(1, 2));
        Generics.copy(nums, List.of(1.5));
        check("copy ints and doubles into List<Number>", nums.size() == 3);

        Map<String, List<Parcel>> g = Generics.groupBy(all, Parcel::city);
        check("groupBy keys in encounter order", List.copyOf(g.keySet()).equals(List.of("Leeds", "Oslo")));
        check("groupBy Leeds has 2", g.get("Leeds").size() == 2);
        check("countAll wildcard", Generics.countAll(all) == 3 && Generics.countAll(List.of("x")) == 1);

        check("firstOfType hit", Generics.firstOfType(List.of("x", a), Parcel.class) == a);
        check("firstOfType miss", Generics.firstOfType(List.of("x"), Parcel.class) == null);

        Page<Parcel> page = new Page<>(all, 0, 1);
        check("page map", page.map(Parcel::id).content().equals(List.of("A", "B", "C")));
        List<Parcel> editable = new ArrayList<>(all);
        Page<Parcel> copied = new Page<>(editable, 0, 1);
        editable.clear();
        check("page copies content", copied.content().size() == 3);

        Repository<Parcel, String> repo = new InMemoryRepository<>(Parcel::id);
        all.forEach(repo::save);
        check("repo find hit", repo.findById("B").orElseThrow() == b);
        check("repo find miss", repo.findById("Z").isEmpty());
        check("repo findAll order", repo.findAll().equals(all));

        Pair<String, Integer> p = Pair.of("x", 1);
        check("pair swap", p.swap().equals(Pair.of(1, "x")));

        System.out.println(passed + " passed, " + failed + " failed");
        if (failed > 0) System.exit(1);
    }
}
java -cp out dev.ggorantala.parcels.Checks
PASS shelf size
PASS shelf get
PASS shelf asList is a copy
PASS shelf map changes type
PASS asList immutable
PASS maxOf parcels
PASS maxOf strings
PASS maxOf empty throws
PASS copy into List<Object>
PASS copy ints and doubles into List<Number>
PASS groupBy keys in encounter order
PASS groupBy Leeds has 2
PASS countAll wildcard
PASS firstOfType hit
PASS firstOfType miss
PASS page map
PASS page copies content
PASS repo find hit
PASS repo find miss
PASS repo findAll order
PASS pair swap
21 passed, 0 failed

One check failed on my first run, and it was my test that was wrong, not the library. I'd asserted page.content() != all to prove Page copies its input, but List.copyOf on an already-immutable list (like the one List.of returns) hands back the same instance. The copy is only real when the source is a mutable ArrayList, so the check now clears an editable list after building the page and asserts the page still has three items. If you've ever written List.copyOf and wondered whether it allocates, that's the answer: not always.

What the compiler erases, and what it keeps

Erasure means the compiler checks your generics, then removes them. At runtime a Shelf<Parcel> is just a Shelf. You can see it in the bytecode:

javap -p -s -cp out dev.ggorantala.parcels.Shelf
  public void put(T);
    descriptor: (Ljava/lang/Object;)V
  public T get(int);
    descriptor: (I)Ljava/lang/Object;

The T is still in the signature (that's what the compiler reads), but the descriptor, the part the JVM runs, says Object. For maxOf, whose T extends Comparable<? super T>, erasure uses the bound instead: the compiler inserts a checkcast to Comparable. Every place you call shelf.get(0) as a Parcel, the compiler also inserts a hidden cast. That's why the raw shelf fails at the cast, not at put.

Traps.java shows what that costs you. Run it:

src/main/java/dev/ggorantala/parcels/Traps.java

package dev.ggorantala.parcels;

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

/** Runtime facts about erasure, printed rather than asserted. */
public class Traps {
    @SuppressWarnings({"unchecked", "rawtypes"})
    public static void main(String[] args) {
        System.out.println("same class: " + (new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass()));

        List<String> names = new ArrayList<>();
        List raw = names;
        raw.add(42);                       // compiles with a warning, runs fine
        System.out.println("names.size() after raw add: " + names.size());
        try {
            String s = names.get(0);       // the compiler's hidden cast fails here
            System.out.println(s);
        } catch (ClassCastException e) {
            System.out.println("heap pollution surfaces later: ClassCastException");
        }

        Shelf<Parcel>[] arr = new Shelf[2]; // legal, but unchecked
        Object[] objs = arr;
        objs[0] = new Shelf<String>();      // no ArrayStoreException: erasure
        System.out.println("stored Shelf<String> in Shelf<Parcel>[]: " + (objs[0] != null));

        Parcel p = new Parcel("P9", "Rome", 10, Status.REGISTERED);
        List<Object> mixed = List.of("x", 7, p);
        System.out.println("firstOfType Parcel: " + Generics.firstOfType(mixed, Parcel.class).id());
        System.out.println("firstOfType Long: " + Generics.firstOfType(mixed, Long.class));
    }
}
same class: true
names.size() after raw add: 1
heap pollution surfaces later: ClassCastException
stored Shelf<String> in Shelf<Parcel>[]: true
firstOfType Parcel: P9
firstOfType Long: null

Read the five lines in order:

  1. A List<String> and a List<Integer> have the same class at runtime.
  2. Adding an Integer through a raw List reference compiled (with a warning I suppressed) and ran. The list now holds a number while claiming to hold strings. This is called heap pollution.
  3. The failure shows up later, at an unrelated line, as a ClassCastException. That's the real danger: the bug and the crash are far apart.
  4. A Shelf<Parcel>[] array accepts a Shelf<String> without an ArrayStoreException, because at runtime both are Shelf. This is why Java forbids new T[], and why mixing arrays and generics is a bad idea. Use List<Shelf<Parcel>>.
  5. firstOfType is the workaround for when you genuinely need the runtime type: pass Parcel.class (a Class<T> token) and call type.isInstance and type.cast. It returned the parcel for Parcel.class and null for Long.class.

Errors I hit, and the fixes

All of these are from javac 21.0.12.1. I forced each one in a one-line class compiled against the library.

What I wrote Message Cause and fix
Shelf<int> s unexpected type / required: reference / found: int Type arguments must be reference types. Use Shelf<Integer>.
new T() inside class X<T> unexpected type / required: class / found: type parameter T The JVM doesn't know what T is at runtime. Pass a Supplier<T> or a Class<T> instead.
new T[3] generic array creation Same cause. Use a List<T>, or (T[]) new Object[3] inside a class that never exposes the array.
o instanceof List<String> Object cannot be safely cast to List<String> The <String> isn't there at runtime. Test o instanceof List<?> and check the elements.
List<Object> os = parcels; List<Parcel> cannot be converted to List<Object> Generics are invariant. Take List<? extends Object> or write a generic method.
l.add(1) on List<? extends Number> int cannot be converted to CAP#1 You can read from an extends wildcard, never write to it. If you need to write, you want ? super.
Generics.maxOf(List.of(new Object())) inference variable T has incompatible bounds Object isn't Comparable. The bound did its job.
static T t; in class X<T> non-static type variable T cannot be referenced from a static context Static members belong to the class, not an instance. Give the static method its own <T>.
Shelf<Parcel> s = new Shelf<>(); s.put("oops") String cannot be converted to Parcel That's the whole point of generics. Fix the argument.

If you're on a JDK older than 16, public record Pair<A, B> stops compiling. I tried --release 11 and got records are not supported in -source 11. Replace the records with ordinary classes, or upgrade.

Variations worth knowing

  • Bounded to several types. <T extends Number & Comparable<T>> takes one class bound followed by interface bounds, joined with &. If one of them is a class, it must come first.
  • Sealed and generic together. A sealed interface Result<T> with two record cases is the usual shape for a "value or error" return. How to use sealed classes and interfaces goes through sealed types.
  • Don't reinvent what the JDK has. Optional<T> is a generic class, and if you haven't read how it's meant to be used, How to use Optional correctly is the next stop. Method references like Parcel::city are what let map and groupBy read well; see What are method references.
  • Builders. If a generic class grows a dozen options, How to implement the builder pattern is how I'd handle it.
  • Tests. The main-method checks are a stand-in. When you have JUnit, How to write unit tests with JUnit 5 shows the real thing (that article is planned and may not be live yet).
  • Production habit. Turn on -Xlint:all in your build and treat unchecked warnings as bugs. Every one of them is a spot where the compiler has stopped protecting you.

Recap

  • Add <T> after a class or interface name to make it generic; add it before the return type to make a single method generic.
  • Static methods can't use the class's T, so declare their own.
  • Use extends bounds when you need to call methods on T: <T extends Comparable<? super T>>.
  • Producer extends, consumer super: read from ? extends, write to ? super.
  • Generics exist only at compile time. No new T(), no new T[], no instanceof List<String>; pass a Class<T> when you must have the type at runtime.
  • Never use raw types in new code. They switch the checking off.

Generics questions people ask

What's the difference between a generic class and a generic method? A generic class declares its type parameter after the name (Shelf<T>) and every instance fixes it. A generic method declares one before the return type (<R> Shelf<R> map(...)) and each call can pick a different type. A generic class can contain generic methods, as Shelf does.

Why can't I create an instance of T or an array of T? After erasure, the JVM doesn't know what T was, so new T() and new T[n] have nothing to build. Pass a Supplier<T> or Class<T>, or use a List<T>.

When do I use ? extends and when ? super? If the parameter produces values you read, use ? extends T. If it consumes values you write, use ? super T. If you only need Object, such as a size, use ?.

Is List<Object> the same as List<?>? No. A List<Object> accepts any object but only another List<Object> can be assigned to it. A List<?> accepts any list but you can't add to it.

References

how-to-tutorialscore-javajava-tutorials

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments