Week 3 - Data Structures & Testing 

Collections

Generics

Enums

Stream APIs

Unit Testing with JUnit

Debugging in Java

Practice

Assignment

Back end Track

Introduction

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.

From Raw Types to Generics

<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:

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 AKA Parameterized Types

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>.

Using Generics with Collections

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.

Writing Generics

In the next two sections, you will write a basic generic class and a basic generic method.

Writing a generic class

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());
}

Writing a generic method

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.

Naming Conventions

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:

When 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);

Bounded Type Parameters

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?

Why Generics Matter?

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.

Exercise

Generic Swap Method

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!");
}

Running code from the terminal

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!

Running code in IntelliJ IDEA

<aside> 💡

</aside>

<aside> 💡

</aside>