Mastering Java Generics: Type-Safe Coding Made Easy

Mastering Java Generics: Type-Safe Coding Made Easy

Why do we need Java Generics?

Before Java introduced Generics (in Java 5), handling different data types was a messy and error-prone process. Developers had to use raw Object types and manually cast them, which led to issues like type safety problems, runtime errors, and unnecessary casting.

Problems Before Generics:

  1. Type Safety Issues

    Since everything was stored as Object, there was no way to enforce type correctness at compile time. Errors would appear only at runtime.
    For example take the example of this class

    Now let’s see how we can implement this

  1. Manual Type Casting
    Since Java didn’t know the type beforehand, developers had to manually cast objects, making the code verbose and risky.

  2. Code Duplication for Different Types
    Before generics, if you wanted a Box class that worked for different types, you had to create multiple versions of the same class, to ensure type safety.

     public class IntegerBox {
         Integer item;
    
         public Integer getItem() {
             return item;
         }
    
         public void setItem(Integer item) {
             this.item = item;
         }
     }
    
     public class StringBox {
         private String item;
    
         public String getItem() {
             return item;
         }
    
         public void setItem(String item) {
             this.item = item;
         }
     }
    

Now let’s see how Generics in java which was introduced in Java 5 solves this problem, let’s write a generic class and then will understand what it does and how it does

public class Container <T>{
    T item;
    public void getItem(T item){
        this.item = item;
    }
    public T setItem(){
        return this.item;
    }
}

Explanation of Generics in This Code

1. <T> in Container<T>

  • The <T> inside Container<T> is a generic type parameter, meaning T can be any object type (like String, Integer, Person, etc.).

  • This allows Container to work with multiple types without rewriting the class.

2. T item;

  • This is a generic field. It holds a value of type T, which will be replaced with a specific type when an object of Container is created.

3. public void setItem(T item)

  • This method accepts an item of type T and assigns it to the item field.

  • Since T is defined as a generic type, this method can take any object type.

4. public T getItem()

  • This method returns the stored item.

  • The return type T ensures the method returns the same type that was initially stored.

Now how do I implement this in code?

public class Main {
    public static void main(String[] args) {
        Container<String> stringContainer = new Container<>();
        stringContainer.setItem("123");
        System.out.println(stringContainer.getItem());

        Container<Integer> integerContainer = new Container<>();
        integerContainer.setItem(123);
        System.out.println(integerContainer.getItem());

        /*
        * Here we mention the type to be put in the Container class
        * which ensures type safety, and also we don't need to declare
        * separate class for each data type. A single class can handle
        * different data type while ensuring type safety
        * */
    }
}

Java allows multiple generic type parameters in a class or method. This makes the class more flexible, allowing it to store and operate on multiple different types.

public class Pair <K, V>{
    private K key;
    private V value;

    public void set(K key, V value){
        this.key = key;
        this.value = value;
    }

    public K getKey(){
        return this.key;
    }

    public V getValue(){
        return this.value;
    }
}

What is Bounded Generics?

Bounded Generics restricts T to a specific group of types.

  • It ensures that only certain types are allowed.

  • This makes the code safer and more useful in some cases.

Upper Bounded Generics(extends)

class NumberBox<T extends Number> {  // T must be a Number or subclass
    private T item;

    public void set(T item) { this.item = item; }
    public T get() { return item; }

    public double doubleValue() { return item.doubleValue(); } // Works only for Numbers
}

✔ Allowed: Integer, Double, Float, etc.
❌ Not Allowed: String, Boolean, etc.

Usage:

public class Main {
    public static void main(String[] args) {
        NumberBox<Integer> intBox = new NumberBox<>();
        intBox.set(10);
        System.out.println(intBox.doubleValue());  // Output: 10.0

        NumberBox<Double> doubleBox = new NumberBox<>();
        doubleBox.set(5.5);
        System.out.println(doubleBox.doubleValue());  // Output: 5.5

        // NumberBox<String> strBox = new NumberBox<>(); // ❌ ERROR: String is not a Number
    }
}

Lower Bounded Generics (super)

If you want to accept T or any of its superclasses, use super:

class PrintList<T> {
    public void printNumbers(List<? super Integer> list) {  // Accepts Integer or its superclasses
        for (Object obj : list) {
            System.out.println(obj);
        }
    }
}

✔ Allowed: List<Integer>, List<Number>, List<Object>
❌ Not Allowed: List<Double>, List<String>

Lower bound (? super T) means "accept T or any of its superclasses".

When to Use Bounded Generics?

  • Use extends when you need to restrict to a specific category (e.g., Number, Animal).

  • Use super when you want to allow higher-level types for flexibility (e.g., Integer and its parents).

Bounded generics = Generics with Rules
It helps control what types are allowed, making the code safer and more predictable!

Multiple Bounds in Generics

Sometimes, you want a generic type to follow more than one rule. This is where multiple bounds come in!

<T extends ClassA & InterfaceB>
  • T must be a subclass of ClassA and implement InterfaceB.

  • You can have one class and multiple interfaces (since Java allows multiple interface inheritance).

Suppose we have a interface Printable

public interface Printable {
    void print();
}

and also we have a class MyNumber which extends the feature of Number.class and Printable interface.

public class MyNumber extends Number implements Printable{
    private final int value;

    public MyNumber(int value) {
        this.value = value;
    }

    @Override
    public void print() {
        System.out.println("My Number :: "+this.value);
    }

    @Override
    public int intValue() {
        return this.value;
    }

    @Override
    public long longValue() {
        return this.value;
    }

    @Override
    public float floatValue() {
        return this.value;
    }

    @Override
    public double doubleValue() {
        return this.value;
    }
}

Now let’s look at how do we implement multiple bounds with Generics

public class Box <T extends Number & Printable>{
    private T item;

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

    public void print(){
        this.item.print();
    }
}

Here we can see that the Box can only accept classes which does extends Number and implements the Printable interface. Now, let’s look at how to implement this Box class:

public class Main {
    public static void main(String[] args) {
        Box<MyNumber> box = new Box<>(new MyNumber(23));
        box.print();
    }
}

Generic Methods

A generic method is a method that can work with any type, instead of being limited to a specific one.

Syntax:

<T> void methodName(T param) { }
  • <T> before the method name means this method is generic.

  • T param → The method accepts any type T.

public class Main {
    public static void main(String[] args) {
       Integer[] integers = {12, 14, 15, 89, 98, 114};
       String[] strings = {"abc","def","ert","lif"};
       printArray(integers);
       printArray(strings);
    }

    public static <T> void printArray(T[] array){
        for(T element : array){
            System.out.print(element + " ");
        }
        System.out.println();
    }
}

And the output is:

Why Use Generic Methods?

Reusable → Works for any data type.
Type-Safe → No need for casting.
Less Code → No need to write separate methods for different types.

🚀 Think of it like a vending machine that accepts any coin! 🏧

Generic Wildcards

Wildcards (?) in generics mean "I don’t know the exact type, but I want some flexibility!"

Types of Wildcards in Generics

1. Unbounded Wildcard (?) → Accepts any type

public static void printList(List<?> list) {  // '?' can be any type
    for (Object item : list) {
        System.out.println(item);
    }
}

✅ Works for List<Integer>, List<String>, List<Double>, etc.


2. Upper Bounded Wildcard (? extends Type) → Accepts Type or its Subclasses

public static void printNumbers(List<? extends Number> list) {  
    for (Number num : list) {
        System.out.println(num);
    }
}

✅ Works for List<Integer>, List<Double>, List<Float> (because they extend Number).
❌ Does NOT work for List<String>, List<Object> (because they don’t extend Number).


3. Lower Bounded Wildcard (? super Type) → Accepts Type or its Superclasses

public static void addNumbers(List<? super Integer> list) {
    list.add(10);
    list.add(20);
}

✅ Works for List<Integer>, List<Number>, List<Object> (because they are Integer’s superclasses).
❌ Does NOT work for List<Double>, List<String>.


Why Use Wildcards?

  • ?When you don’t care about the exact type.

  • ? extends TypeWhen you only read the values (but don’t modify them).

  • ? super Type When you modify values (like adding elements).

🚀 Think of it like a movie ticket:

  • 🎟️ ? → Any seat (any type).

  • 🎟️ ? extends VIP → Only VIP or higher.

  • 🎟️ ? super Regular → Regular or higher (VIP, Premium).

Generics in Enums

Enums in Java cannot be directly generic, but they can use generic methods or be used inside generic classes.

For example:

public enum Operation {
    SUM,SUBSTRACT,MULTIPLY,DIVIDE;

    public <T extends Number> double operate(T a, T b){
        switch (this){
            case SUM -> {
                return a.doubleValue() + b.doubleValue();
            }
            case SUBSTRACT -> {
                return a.doubleValue() - b.doubleValue();
            }
            case MULTIPLY -> {
                return a.doubleValue() * b.doubleValue();
            }
            case DIVIDE -> {
                return a.doubleValue() / b.doubleValue();
            }
            default -> {
                throw new AnnotationFormatError("Invalid operation selected");
            }
        }
    }
}
public class Main {
    public static void main(String[] args) {
        System.out.println(Operation.SUM.operate(12,17));
    }
}

Conclusion: The Power of Generics in Java

Java Generics bring flexibility, type safety, and reusability to your code. They allow you to write cleaner, more efficient, and error-free programs by enabling parameterized types. Whether you are working with generic classes, methods, bounded types, wildcards, or even integrating them with enums, Generics help eliminate redundancy and unnecessary type casting.

By mastering Generics, you can:
Write reusable code that works with multiple data types.
Ensure type safety, preventing ClassCastException at runtime.
Improve readability by reducing boilerplate type conversions.

From collections (List<T>, Map<K, V>) to custom data structures and APIs, Generics are a must-have tool for writing robust and scalable Java applications. Embrace them, and your code will be more powerful, efficient, and future-proof!