Week 3

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 powerful Java feature that let you write classes, interfaces, and methods that work with many different types while maintaining full type safety from the compiler (no manual casting or runtime surprises).

Coming from Node.js, where types are dynamic, you often rely on runtime checks (example: id === "42"). With Java Generics, you'll appreciate the flexibility of writing reusable, generic code while still enjoying the compile-time safety that Java offers!

From Raw Types to Generics

<aside> 💭

Watch this video about Generics until the 7:22 mark which demonstrates the problems before Generics

</aside>

https://www.youtube.com/watch?v=7i3Rliqzquw

Key takeaway from this video:

Prior to Java 5, Collections in Java were somewhat like JavaScript. ArrayLists, for example, used to work with Object (raw type), so all elements in them would be Object raw types until they were cast to a more specific type.

Since Java is backwards compatible, we can still experiment with the pre-Generics collections:

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

💬 What happens when you cast the second element (42) in groceries list to String?

Of course, right now our ClassCastException is obvious as we have a small list and not much code. But imagine if you have an application that is holding user ids in an array and some front-end bug causes an integer to be fed into your array and all of a sudden your, say, archiving system starts throwing ClassCastExceptions because it was expecting a String.

Generics AKA Parameterized Types

Generics give you flexibility with typing. They allow you to pass the type as a parameter much like a method and its arguments so that before compilation, all objects are unwrapped into their final type.

Do you remember the ArrayList signature in Collections chapter? This one

public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable

With <E> the type of the elements that a collection holds is now parameterized and can be known during instantiation of an ArrayList.

Once you name a type, you can pass it the same way to any class you’re extending or implementing as is done with ArrayList to AbstractList, List and so on. You can also pass it on to fields or methods in your class. So E is available everywhere in your class and it will only be known during instantiation.

Using Generics with Collections

So how Generics solve the problem we had with raw type Object collections above?

By defining the type during instantiation, compiler will restrict all element operations on the ArrayList to operate only on the target type or a compatible one (Interface implementation). Going against the compiler will be much more quickly caught by your IDE rather than during runtime.

Example:

// Passing String as type parameter to have ArrayList work with Strings.
List<String> groceries = new ArrayList<>();

groceries.add("Milk");
groceries.add(42); // Now compiler catches this problem pre-runtime

IO.println(groceries.get(0).toUpperCase()); // No more casting needed

Library writers use Generics extensively to reduce the amount code they have to write. They no longer have to make an ArrayList for Strings, one for Integers and so on. You just parameterize the type and make your classes Generic!

Writing Generics

In the following 2 sections, we’ll demonstrate writing a very basic Generics class and method.

Writing a generic class

With that aside, let’s write a very basic generics 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> ⚠️

Usually parameterized types are named with single letters, example: <T>. That’s standard and is following a naming convention which you’ll see below. Above though, we’re using a longer name to demonstrate that it’s really a name for a type parameter.

</aside>

Now you can use the above class with any type with the caveat that you know said type at instantiation

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 nice way to learn about generic methods is to go back to our groceries list problem above.

Remember how we wanted to add both Integers and Strings to our list? That is also possible by using generic method that handles the conversion logic:

// No access modifier. If Java version < 25 then you need public static keywords
// We're declaring our type parameter with <T> and reusing it for element argument's type
<T> boolean addToList(List<String> list, T element) {
		// We're handling different types checking the instance
		// We decided we can handle String and Integer and fail for anything else
    if (element instanceof Integer) {
		    // We're handling conversion logic for Integer. 42 -> "42"
        String eAsString = String.valueOf(element);

        list.add(eAsString);
        return true;
    // Our preferred type. Everything else will have to be converted
    } else if (element instanceof String e) {
        list.add(e);
        return true;
    // Better is to raise an error for other types but for demo we return false
    } else {
        return false;
    }
}

Now the above method allows us to do the following:

void main() {
    List<String> groceries = new ArrayList<>();

		//Calling our method above instead
    addToList(groceries, "Milk");
    addToList(groceries, 42);

    IO.println(groceries.get(0).toUpperCase());
    // Now a valid operation since 42 would be converted to String
    IO.println(groceries.get(1).toUpperCase());
}

Naming Conventions

In Java, type parameters follow a simple, widely-accepted convention: use a single uppercase letter (never a full word or lowercase so not what we did for our class above unless you have a very good reason). This keeps generic code short and instantly recognizable to every Java developer.

The most common letters you’ll see everywhere (including in ArrayList<E>, HashMap<K,V>, etc.) are:

When you need more than one parameter, continue with S, U, V (or T1, T2).

Simple example for a 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 your generic code to work only with certain types. For example only Collection types which include Lists and Sets or only things that can be Iterated (Implementing Iterable). That’s exactly what a bounded type parameter does, it bounds the types so you have to handle less cases in your method.

Adding extends right after the type letter. The most common one you’ll see is <T extends Comparable<T>>, which reads in English as: T must be a type that knows how to compare itself to another T.

This is super useful for sorting, finding max/min, etc., and it’s exactly how Collections.max() works under the hood.

Here's a small example, which is a generic method that finds the larger of two values (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’ve now seen how generics give you compile-time safety and eliminate risky casting. There are two more powerful reasons they matter in real backend development.

Exercise

Generic Swap Method

Implement the swap method in the below Java code such that the method swaps 2 array elements by their index.

<T> void swap(List<T> list, int index1, int index2) {
    // TODO: implement here
}

// Convenience method to assert the equality of 2 Strings and fail the program if 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 fine for the most part
    IO.println("Method swap works as expected!");
}

Expected output is Method swap works as expected!


The HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0

CC BY-NC-SA 4.0 Icons

*https://hackyourfuture.net/*

Found a mistake or have a suggestion? Let us know in the feedback form.