Generic Classes

Su Suriya Ravichandran Updated 14 Nov 2026
7 min read ·Lesson 88 of 133

A generic class is a class declared with one or more type parameters: placeholders for types that the user of the class supplies later. The class is written once, and every use of it fills in concrete types: Box<String>, Box<Integer>, Repository<Student, Integer>. Inside the class, the type parameter is used like any other type, and the compiler checks every use against the type the caller chose. All of Java's collection classes are generic classes.

Declaring a Generic Class

Type parameters go in angle brackets right after the class name:

public class Box<T> {
    private T value;

    public Box(T value) {
        this.value = value;
    }

    public T get() {
        return value;
    }

    public void set(T value) {
        this.value = value;
    }
}

T can appear as a field type, a parameter type, a return type, and a local variable type. It stands for "whatever type this particular Box was created with."

Box<String> name = new Box<>("Ada");
String s = name.get();              // returns String: no cast

Box<Integer> count = new Box<>(10);
count.set(count.get() + 1);         // Integer arithmetic via unboxing

name.set(42);                       // compile error: int cannot be converted to String

Each parameterized type, Box<String> or Box<Integer>, behaves as if T had been replaced by that type throughout the class.

Without generics

The alternative is a class that stores Object:

public class ObjectBox {
    private Object value;
    public Object get() { return value; }
    public void set(Object value) { this.value = value; }
}

ObjectBox b = new ObjectBox();
b.set("text");
Integer n = (Integer) b.get();      // compiles, then throws ClassCastException

It accepts anything, requires casts on every read, and fails at runtime. The generic version accepts only the declared type and fails at compile time instead.

Multiple Type Parameters

A class can have several type parameters, separated by commas:

public class Pair<A, B> {
    private final A first;
    private final B second;

    public Pair(A first, B second) {
        this.first = first;
        this.second = second;
    }

    public A first() { return first; }
    public B second() { return second; }

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

    @Override
    public String toString() { return "(" + first + ", " + second + ")"; }
}
Pair<String, Integer> p = new Pair<>("Ada", 36);
Pair<Integer, String> q = p.swap();
System.out.println(q);              // (36, Ada)

Map<K, V> and Map.Entry<K, V> are two-parameter generic types from the JDK. Generic records are written the same way: record Pair<A, B>(A first, B second) { }.

Bounded Type Parameters

By default, a type parameter can be any reference type, so inside the class you can only call methods that every object has (toString, equals, hashCode). A bound restricts the type parameter to a type and its subtypes, which makes that type's methods available:

public class NumberBox<T extends Number> {
    private final T value;

    public NumberBox(T value) { this.value = value; }

    public double doubled() {
        return value.doubleValue() * 2;     // allowed: every Number has doubleValue()
    }
}
new NumberBox<>(21).doubled();        // 42.0
new NumberBox<>(1.5).doubled();       // 3.0
new NumberBox<>("21");                // compile error: String is not a Number

extends is used for both classes and interfaces in a bound. A type parameter can have multiple bounds joined with &, with at most one class, listed first:

class Ranked<T extends Number & Comparable<T>> { ... }

Bounded types are covered in more depth in the Java Generics section.

Generic Classes and Inheritance

A generic class can extend or implement generic types, either fixing the type argument or passing its own parameter along:

// Fixes the type: a StringList is always a list of strings
class StringList extends ArrayList<String> { }

// Passes its parameter along: a LoggingList<E> is a List<E>
class LoggingList<E> extends ArrayList<E> {
    @Override
    public boolean add(E e) {
        System.out.println("adding " + e);
        return super.add(e);
    }
}

A subclass can also add its own type parameters, as in class TimedBox<T, U> extends Box<T>.

Generic types are not covariant

Even though Integer is a subtype of Number, Box<Integer> is not a subtype of Box<Number>:

Box<Integer> ints = new Box<>(1);
Box<Number> nums = ints;            // compile error: incompatible types

If it were allowed, code could call nums.set(3.14) and put a Double into a box that other code still treats as Box<Integer>. Wildcards (Box<? extends Number>) express "a box of some kind of Number" safely; they have their own lesson.

Generic Methods Inside Generic Classes

A generic class's methods can use the class's type parameter, and can also declare additional type parameters of their own:

public class Box<T> {
    private final T value;
    public Box(T value) { this.value = value; }
    public T get() { return value; }

    public <R> Box<R> map(Function<? super T, ? extends R> f) {
        return new Box<>(f.apply(value));
    }
}

Box<Integer> length = new Box<>("java").map(String::length);
System.out.println(length.get());   // 4

map introduces R, the type of the result, which can differ from T. This is the same pattern used by Optional.map and Stream.map. Generic methods have their own lesson.

What You Cannot Do with a Type Parameter

Generics are checked at compile time and then erased: at runtime, Box<String> and Box<Integer> are the same class.

Box<String> s = new Box<>("x");
Box<Integer> i = new Box<>(1);
System.out.println(s.getClass() == i.getClass());   // true

Because the actual type argument is not available at runtime, a few things are not allowed inside a generic class:

Not allowedWhyCommon workaround
new T()The runtime does not know what T isAccept a Supplier<T> or a Class<T>
new T[10]Same reasonUse a List<T>, or create an Object[] and cast carefully
static T fieldStatic members are shared by all parameterizationsMake the member non-static
obj instanceof TT is erasedPass a Class<T> and use type.isInstance(obj)
Primitive type arguments (Box<int>)Type arguments must be reference typesUse wrapper types (Box<Integer>)
public class Factory<T> {
    private final Supplier<T> creator;
    public Factory(Supplier<T> creator) { this.creator = creator; }
    public T create() { return creator.get(); }
}

Factory<StringBuilder> f = new Factory<>(StringBuilder::new);
StringBuilder sb = f.create();

Naming and Design Guidelines

  • Use single uppercase letters for type parameters: T, E, K, V, R. Longer names (TEntity) are occasionally used but are unusual in Java.
  • Make a class generic when it stores or processes values without depending on their exact type: containers, wrappers, caches, results, pages, events.
  • Add a bound when the class needs to call methods of the type: T extends Comparable<T>, T extends Entity<ID>.
  • Do not make a class generic just because you can. If it only ever works with one type, a plain class is clearer.

Practical Example: A Generic In-Memory Repository

Many applications need the same operations for different kinds of records: save, find by ID, search, delete. A generic repository implements them once for any entity type and any ID type.

import java.util.*;
import java.util.function.Predicate;

public class Repo {
    interface Entity<ID> { ID id(); }

    static class InMemoryRepository<T extends Entity<ID>, ID> {
        private final Map<ID, T> store = new LinkedHashMap<>();

        T save(T entity) {
            store.put(entity.id(), entity);
            return entity;
        }

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

        List<T> findWhere(Predicate<T> condition) {
            List<T> result = new ArrayList<>();
            for (T e : store.values()) {
                if (condition.test(e)) result.add(e);
            }
            return result;
        }

        boolean delete(ID id) { return store.remove(id) != null; }
        int count() { return store.size(); }
    }

    record Student(Integer id, String name, int year) implements Entity<Integer> { }
    record Course(String id, String title, int hours) implements Entity<String> { }

    public static void main(String[] args) {
        InMemoryRepository<Student, Integer> students = new InMemoryRepository<>();
        students.save(new Student(1, "Ada", 2));
        students.save(new Student(2, "Linus", 3));
        students.save(new Student(3, "Grace", 2));

        InMemoryRepository<Course, String> courses = new InMemoryRepository<>();
        courses.save(new Course("JAVA101", "Java Basics", 20));
        courses.save(new Course("DOCK210", "Docker", 12));

        System.out.println(students.findById(2).map(Student::name).orElse("none"));
        System.out.println(students.findWhere(s -> s.year() == 2));
        System.out.println(courses.findById("PY999").isPresent());
        System.out.println(courses.findWhere(c -> c.hours() > 15));

        students.delete(1);
        System.out.println(students.count() + " students, " + courses.count() + " courses");
    }
}

Output:

Linus
[Student[id=1, name=Ada, year=2], Student[id=3, name=Grace, year=2]]
false
[Course[id=JAVA101, title=Java Basics, hours=20]]
2 students, 2 courses

InMemoryRepository has two type parameters: T, the entity type, and ID, the key type. The bound T extends Entity<ID> guarantees that every entity has an id() method returning the right kind of key, which is what lets save call entity.id(). One class serves a repository of Students keyed by Integer and a repository of Courses keyed by String. The compiler keeps them apart: saving a Course into the student repository, or looking up a student by a String ID, does not compile. findById returns Optional<T>, so callers get a correctly typed result (Optional<Student>) without casts. This is the same shape as the repository interfaces in Spring Data JPA, covered in the frameworks section.

0 Comments

Reviewed before they appear

No comments yet.

Java
Java Introduction Java Features Java Syntax Java Comments Java Statements Java Output Java Variables Java Data Types Java Type Casting Java Operators Java Strings Java Numbers Java Math Java Booleans Java User Input Java Conditions Java If Else Java Switch Java While Loop Java For Loop Java Break Java Continue Java Arrays Java Methods Java Parameters Java Return Values Java Scope Java Recursion Install Java Install JDK Java IDEs Java JDK Java JRE Java JVM Java Compiler Java File Compile Java Run Java JShell Basics Java Packages Java OOP Java Classes Java Objects Class Methods Constructors Constructor Overload this Keyword static Keyword final Keyword Encapsulation Inheritance Polymorphism Abstraction Access Modifiers super Keyword Method Overload Method Override Instanceof Operator Nested Classes Inner Classes Anonymous Classes Enums Records Array Basics Array Length Loop Arrays Multidimensional Arrays Array Copy Array Sorting Array Search ArrayList LinkedList HashSet TreeSet HashMap TreeMap Queue Interface Deque Interface Stack Class Iterator List Interface Set Interface Map Interface Collections Class Comparable Comparator Generics Basics Generic Classes Generic Methods Wildcards String Basics String Length String Methods String Concatenation String Compare String Search String Replace String Split String Format StringBuilder StringBuffer Text Blocks Regular Expressions Number Types Integer Class Float Class Double Class BigInteger BigDecimal Math Class Random Numbers Number Format Date Basics LocalDate LocalTime LocalDateTime ZonedDateTime Date Formatting Date Parsing Period Class Duration Class Java Instant Class Java Exception Basics Java try...catch Java Multiple Catch Blocks Java finally Block Java throw Keyword Java throws Keyword Java Checked Exceptions Java Unchecked Exceptions Java Custom Exceptions Java try-with-resources Java Exception Chaining
Ask about this post
AI Ask about this post

Ask questions about Generic Classes and get answers drawn from it.

Signed-in readers only.