In this chapter, you'll learn about generics, a Java feature that lets you write classes, interfaces, and methods that work with different types while still giving the compiler enough information to check your code.
Coming from JavaScript and Node.js, you are used to flexible values and runtime checks, such as id === "42". Java generics give you a different balance: you can still write reusable code, but the compiler can catch many type mistakes before the program runs.
<aside> 💭
Watch this video about generics until the 7:22 mark. It demonstrates the problems Java developers had before generics were added.
</aside>
https://www.youtube.com/watch?v=7i3Rliqzquw
Key takeaways from the video:
Object.Object back, you need to cast it to a more specific type before you can use type-specific methods.ClassCastException.Before Java 5, code that used collections often looked more like JavaScript: one list could contain different kinds of values. In Java, that worked because collection methods accepted and returned Object.
Raw types still exist for backward compatibility, so we can experiment with the old style:
void main() {
List groceries = new ArrayList();
groceries.add("Milk");
groceries.add(42);
// Casting from Object to access the String's toUpperCase method later on
String groceryItem = (String) groceries.get(0);
IO.println(groceryItem.toUpperCase());
}
This code compiles, but your IDE may warn you that List and ArrayList are being used as raw types. Raw types bypass the generic type checks that modern Java code normally relies on.
💬 What happens when you cast the second element (42) in the groceries list to String?
In this tiny example, the bug is easy to spot. In a larger backend application, the mistake could be much harder to trace. Imagine an application that stores user IDs in a list. If a frontend bug sends an integer into a list where the backend expects strings, the error may only appear later when another part of the system tries to cast that value to String.
Generics let you make a type into a parameter of a class, interface, or method. For example, ArrayList<String> tells the compiler: this list is meant to contain String values.
The compiler uses that information to check your code before it runs. After compilation, Java mostly removes generic type details through a process called type erasure. You do not need to master type erasure yet. For now, the important point is that generics give you earlier feedback and remove many manual casts.
Do you remember the ArrayList signature from the Collections chapter?
public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable
Here, <E> declares a type parameter. E stands for the type of element the collection holds. When code creates an ArrayList<String>, Java treats E as String for that use of the class.
Once a type parameter is declared, the class can reuse it in fields, methods, superclass declarations, and implemented interfaces. That is why ArrayList<E> can pass the same E to AbstractList<E> and List<E>.
So how do generics solve the raw type problem from the previous section?
By setting the element type, you let the compiler restrict list operations to the target type, or to compatible values where Java allows them. Mistakes are caught by your IDE or compiler much earlier, instead of appearing as runtime errors later.
Example:
// Passing String as type parameter to have ArrayList work with Strings.
List<String> groceries = new ArrayList<>();
groceries.add("Milk");
// groceries.add(42); // <- would give Compiler error: 42 is not a String
IO.println(groceries.get(0).toUpperCase()); // No more casting needed
Because groceries is a List<String>, Java knows that groceries.get(0) returns a String. You can call toUpperCase() directly without casting.
Library authors use generics extensively to reduce duplication. They do not need to write one ArrayList for strings, another ArrayList for integers, and another one for every other type. They write one generic ArrayList<E>, and the user of the class chooses the element type.
In the next two sections, you will write a basic generic class and a basic generic method.
Let's write a small generic class:
package com.hyf;
// CONTENT_TYPE is type parameter that can be passed during instantiation
public class Box<CONTENT_TYPE> {
// From here on, CONTENT_TYPE is like a variable for types you can reuse
private CONTENT_TYPE content;
public void put(CONTENT_TYPE content) {
this.content = content;
}
public CONTENT_TYPE get() {
return content;
}
}
<aside> ⚠️
Type parameters are usually named with single uppercase letters, such as <T>. That is the standard naming convention, and you will see it below. The longer name above is only used to make the idea visible that CONTENT_TYPE is a name for a type parameter.
</aside>
Now you can use the class with any reference type, as long as you choose the type when you use the box. For primitive values, use wrapper types such as Integer instead of int.
import com.hyf.Box;
void main() {
Box<String> nameBox = new Box<>();
nameBox.put("HackYourFuture");
IO.println(nameBox.get());
}
Using it with Integer:
import com.hyf.Box;
void main() {
Box<Integer> ageBox = new Box<>();
ageBox.put(25);
IO.println(ageBox.get());
}
A generic method declares its own type parameter before the return type. A useful way to see this is to return to the groceries list problem.
Remember how we wanted to add both integers and strings to our list? That is possible, but the conversion rules still need to be explicit:
// We're declaring our type parameter with <T> and reusing it for element argument's type
// NOTE: In a traditional class with a static main method, this helper would usually be static too.
<T> boolean addToList(List<String> list, T element) {
// Handle String and Integer and fail for anything else
if (element instanceof Integer) {
// Handling conversion logic for Integer. 42 -> "42"
String elementAsString = String.valueOf(element);
list.add(elementAsString);
return true;
} else if (element instanceof String stringElement) {
list.add(stringElement);
return true;
// Better is to thrown an exception for other types but for simplicity we return false
} else {
return false;
}
}
Now the method allows us to do this:
void main() {
List<String> groceries = new ArrayList<>();
addToList(groceries, "Milk");
addToList(groceries, 42);
IO.println(groceries.get(0).toUpperCase());
IO.println(groceries.get(1).toUpperCase());
}
The method is generic because T can be inferred as String, Integer, or another type at each call. The method still controls what it can actually handle. In this example, it accepts String values directly, converts Integer values to strings, and rejects anything else by returning false.
In Java, type parameters follow a simple, widely accepted convention: use a single uppercase letter. This keeps generic code short and instantly recognizable to other Java developers.
The most common letters you’ll see everywhere (including in ArrayList<E>, HashMap<K,V>, etc.) are:
T – a general TypeE – an Element (often used in collections)K – a KeyV – a ValueN – a NumberWhen you need more than one general type parameter, continue with letters such as S, U, V (or T1, T2).
Here is a simple Pair class:
public class Pair<K, V> {
private K key;
private V value;
public Pair(K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() { return key; }
public V getValue() { return value; }
}
Usage:
Pair<String, Integer> student = new Pair<>("HackYourFuture", 2026);
Sometimes you want generic code to work only with types that have a certain capability. For example, you might only accept values that implement Iterable, or values that implement Comparable so they can be ordered.
A bounded type parameter does that. It limits which types can be used as the type argument.
You add extends after the type parameter. The most common example you will see is <T extends Comparable<T>>, which reads as: T must be a type that can compare itself to another T.
This is useful for sorting, finding a maximum value, or finding a minimum value. Collections.max() uses a related idea under the hood.
Here is a small generic method that finds the larger of two values, but only if the type supports comparison:
<T extends Comparable<T>> T max(T a, T b) {
return a.compareTo(b) >= 0 ? a : b;
}
void main() {
IO.println(max(3, 7));
IO.println(max("apple", "banana"));
}
Because T is bounded to Comparable<T>, the compiler guarantees that compareTo is available. You can't accidentally pass a type that doesn't know how to compare itself.
💬 Is it possible to bound types to String and Integer to make our addToList method above simpler?
You have now seen how generics give you compile-time safety and remove risky casting. There are two more powerful reasons they matter in real backend development.
ArrayList<E>, HashMap<K, V>) and much of Spring Boot are built on generics. When you start writing REST controllers and in Week 4, you will use generics for type-safe JSON responses, request bodies, and more. Learning them well means you can write cleaner APIs from day one.Implement the swap method in the Java code below so that the method swaps two list elements by their index.
Requirements:
<T> void swap(List<T> list, int index1, int index2) {
// TODO: Write the swap logic here.
}
// Convenience method to assert the equality of two strings and fail the program if they are not equal.
void manualAssertString(String str, String equalTo) {
if (!str.equals(equalTo)) {
throw new AssertionError("String " + str + " does not equal " + equalTo);
}
}
void main() {
List<String> names = new ArrayList<>(List.of("Hack", "Your", "Future"));
swap(names, 0, 2);
manualAssertString(names.get(0), "Future");
swap(names, 1, 0);
manualAssertString(names.get(0), "Your");
// If code execution reaches here, the swap method is working as expected.
IO.println("Method swap works as expected!");
}
Above code can be copy pasted into a new file, named SwapExercise.java, and run with JDK 25 just by running java SwapExercise.java in the terminal.
Expected output is Method swap works as expected!
SwapExercise.java file in your source directory<aside> 💡
</aside>
<aside> 💡
</aside>