Sitemap
Dev Genius

Coding, Tutorials, News, UX, UI and much more related to development

Learning Java from Scratch with Arbiter — Generics: Bounded Type Parameters, Wildcards, and PECS

Day 12 of my Arbiter Learning Journey | Why a bound is a promise the compiler holds you to, why ? extends reads and ? super writes, and how a raw type compiles fine and crashes later

10 min readOct 12, 2026

--

Press enter or click to view image in full size
A typed box that admits only comparable values on the left, and two opposing pipes on the right showing data read out of a list with ? extends and written into a list with ? super.
A bound decides what a generic type is allowed to do. A wildcard decides which way data is allowed to flow.

Before we begin, here’s the source code for today’s lesson:

import java.util.ArrayList;
import java.util.List;

class ScoreBox<T extends Comparable<T>> {
private T value;

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

public boolean isHigherThan(T other) {
if (value.compareTo(other) > 0) return true;
else return false;
}
}
// Note: If the class were declared as plain class ScoreBox<T> instead of
// class ScoreBox<T extends Comparable<T>>, the line calling "value.compareTo(other)"
// would fail to compile. The compiler cannot allow it without the bound because a
// generic type parameter T defaults to Object (or upper-bounded by Object), and the
// Object class does not possess a compareTo method. Therefore, the compiler has no
// guarantee that arbitrary type T will support comparison operations.

public class java_12Generics {
public static void main(String[] args) {
// This returns warning when compiled without the operator '<>'
// Note: java_12Generics.java uses unchecked or unsafe operations.
// Note: Recompile with -Xlint:unchecked for details.
// ScoreBox<Integer> intBox = new ScoreBox(50);
// ScoreBox<String> strBox = new ScoreBox("Banana");

ScoreBox<Integer> intBox = new ScoreBox<>(50);
ScoreBox<String> strBox = new ScoreBox<>("Banana");

System.out.println("Integer (50 > 30): " + intBox.isHigherThan(30));
System.out.println("Integer (50 > 60): " + intBox.isHigherThan(60));

System.out.println("String (Banana > Apple): " + strBox.isHigherThan("Apple"));
System.out.println("String (Banana > Cherry): " + strBox.isHigherThan("Cherry"));

List<Integer> intList = List.of(1, 2, 3);
List<Double> doubleList = List.of(1.5, 2.5);
List<Number> numberList = List.of(1, 2.5, 3L);

System.out.println();
System.out.println(sumNumbers(intList)); // 6.0
System.out.println(sumNumbers(doubleList)); // 4.0
System.out.println(sumNumbers(numberList)); // 6.5

List<Number> numberTarget = new ArrayList<>();
List<Object> objectTarget = new ArrayList<>();
List<Integer> intTarget = new ArrayList<>();

fillWithZero(numberTarget, 2);
fillWithZero(objectTarget, 3);
fillWithZero(intTarget, 1);

System.out.println();
System.out.println(numberTarget); // [0, 0]
System.out.println(objectTarget); // [0, 0, 0]
System.out.println(intTarget); // [0]
}

// If we used ? super Number, the method would not accept List<Integer> or List<Double>
// because lower-bounded wildcards only permit supertypes of Number, not subtypes.
public static double sumNumbers(List<? extends Number> list) {
double sum = 0.0;
for (Number value: list) {
sum += value.doubleValue();
}

return sum;
}

// If we used ? extends Integer, the method would not accept List<Number> or List<Object>
// because upper-bounded wildcards make the collection read-only (preventing additions like list.add()).
public static void fillWithZero(List<? super Integer> list, int count) {
for (int i = 0; i < count; i++) {
list.add(Integer.valueOf(0));
}
}
}

Last time, the compiler forced me to handle a checked exception and quietly let an unchecked one slide. Today’s lesson is about the compiler doing more of that work on purpose. Generics let a class or method be written once and still know, at compile time, exactly what type it’s working with. The interesting part isn’t writing List<Integer>. It's the moment you need a generic type to do something, like compare itself, or accept a list of "any kind of number," and the compiler starts asking you to prove it's safe.

Today’s lesson has three moving parts, and each answers a different question. A bounded type parameter asks what am I allowed to call on T? An upper-bounded wildcard (? extends) asks which lists can I read from? A lower-bounded wildcard (? super) asks which lists can I safely write into?

This is Day 12 of my Arbiter Learning Journey, where I’m learning Java from the ground up before eventually building Arbiter, a complete microservices-based test management platform.

In Day 11, I covered try/catch/finally, checked vs. unchecked exceptions, and why try-with-resources closes everything before your catch runs. If you want the theory behind it (termination semantics, checked exceptions, and cleanup), the Day 11 Guide Theory article covers it.

(A note on ordering: Day 11 closed by previewing Sets. Today’s lesson file is Generics, so Sets get deferred again. The roadmap puts Generics under its own category, and Sets under Core Collections.)

Today’s Topics

  • Generic classes: writing ScoreBox<T> once and using it for Integer and String
  • Bounded type parameters: T extends Comparable<T> and why compareTo needs it
  • The diamond operator <>: letting the compiler infer the type argument
  • Upper-bounded wildcards (? extends Number): reading from a family of lists
  • Lower-bounded wildcards (? super Integer): writing into a family of lists
  • PECS: Producer Extends, Consumer Super

A Bound Is What Lets You Call compareTo

The lesson opens with a small generic class:

class ScoreBox<T extends Comparable<T>> {
private T value;

public ScoreBox(T value) {
this.value = value;
}
public boolean isHigherThan(T other) {
if (value.compareTo(other) > 0) return true;
else return false;
}
}

ScoreBox<T> holds one value of some type T. The important part is T extends Comparable<T>. Without it, I verified that the class doesn't compile:

error: cannot find symbol
if (value.compareTo(other) > 0) return true;
^
symbol: method compareTo(T)

An unbounded T could be anything, so the compiler only knows it's an Object. Object has no compareTo. The bound is a promise: whatever T turns out to be, it can compare itself to another T. The compiler checks that promise once, at the class declaration, and then holds every caller to it. Integer and String both implement Comparable, so both are accepted:

ScoreBox<Integer> intBox = new ScoreBox<>(50);
ScoreBox<String> strBox = new ScoreBox<>("Banana");

Output:

Integer (50 > 30): true
Integer (50 > 60): false
String (Banana > Apple): true
String (Banana > Cherry): false

The same method, isHigherThan, compares numbers numerically and strings alphabetically. ScoreBox doesn't know or care how. It only relies on the one method the bound guarantees.

One small thing about new ScoreBox<>(50): the empty <> is the diamond operator. The compiler reads ScoreBox<Integer> on the left and fills in Integer on the right. Leave the angle brackets out entirely and you get something quite different, which I'll come back to in the trap at the end.

? extends Number: A Method That Reads From Many Kinds of List

The next method sums any list of numbers:

public static double sumNumbers(List<? extends Number> list) {
double sum = 0.0;
for (Number value: list) {
sum += value.doubleValue();
}

return sum;
}

List<? extends Number> means a list of some type that is Number or a subclass of it. That's why all three calls work:

List<Integer> intList = List.of(1, 2, 3);
List<Double> doubleList = List.of(1.5, 2.5);
List<Number> numberList = List.of(1, 2.5, 3L);

Output:

6.0
4.0
6.5

This might look unnecessary. Why not just write List<Number>? Because in Java, a List<Integer> is not a List<Number>, even though an Integer is a Number. Generics are invariant. The wildcard is what opens the method up to the whole family of number lists.

Every element, whatever its exact type, can be read as a Number, which is all doubleValue() needs. The method only ever takes values out of the list. Hold onto that.

? super Integer: A Method That Writes Into Many Kinds of List

The last method does the opposite job:

public static void fillWithZero(List<? super Integer> list, int count) {
for (int i = 0; i < count; i++) {
list.add(Integer.valueOf(0));
}
}

List<? super Integer> means a list of some type that is Integer or a superclass of it. So a List<Integer>, a List<Number>, and a List<Object> are all accepted:

List<Number> numberTarget = new ArrayList<>();
List<Object> objectTarget = new ArrayList<>();
List<Integer> intTarget = new ArrayList<>();

fillWithZero(numberTarget, 2);
fillWithZero(objectTarget, 3);
fillWithZero(intTarget, 1);

Output:

[0, 0]
[0, 0, 0]
[0]

Adding an Integer is safe in every one of these, because an Integer fits in a list of Integer, a list of Number, and a list of Object. This method only ever puts values into the list.

Press enter or click to view image in full size
A two-column infographic comparing ? extends Number, which accepts subtypes and lets you read, with ? super Integer, which accepts supertypes and lets you write.
? extends looks down the type ladder and lets values out. ? super looks up the type ladder and lets values in.

PECS: Why the Two Methods Need Opposite Wildcards

The rule that ties these together is PECS: Producer Extends, Consumer Super.

  • sumNumbers reads from its list. The list produces values for the method, so it uses ? extends.
  • fillWithZero writes into its list. The list consumes values from the method, so it uses ? super.

PECS describes the role a parameter plays in that specific method. It isn’t a property of the list itself. That’s why List<Integer> and List<Number> show up as valid arguments to both methods, for different reasons.

The real proof isn’t that the correct versions work. It’s that the wrong versions fail. I swapped each wildcard to the opposite direction and compiled.

Get Someone’s stories in your inbox

Join Medium for free to get updates from this writer.

Swapping sumNumbers to List<? super Number>:

error: incompatible types: List<Integer> cannot be converted to List<? super Number>
error: incompatible types: List<Double> cannot be converted to List<? super Number>
error: incompatible types: CAP#1 cannot be converted to Number

The first two errors are at the call sites. ? super Number only accepts Number or something above it, and List<Integer> is below it. The third error is inside the method body: the for (Number value: list) loop no longer compiles, because a list of "some supertype of Number" might be a List<Object>, and an Object can't be read as a Number.

Swapping fillWithZero to List<? extends Integer>:

error: incompatible types: List<Number> cannot be converted to List<? extends Integer>
error: incompatible types: List<Object> cannot be converted to List<? extends Integer>
error: incompatible types: Integer cannot be converted to CAP#1
where CAP#1 is a fresh type-variable:
CAP#1 extends Integer from capture of ? extends Integer

Same shape. The callers passing List<Number> and List<Object> are rejected, and the list.add(Integer.valueOf(0)) line itself fails. CAP#1 is the compiler's name for "some specific, unknown subtype of Integer." Since it doesn't know which one, it can't verify that an Integer fits.

So each wrong direction breaks in two places: the callers it was meant to serve, and the method body itself. ? extends blocks writing, ? super blocks reading as anything more specific than Object. That's PECS confirmed by the compiler, not just memorized.

Running It

Here’s the complete output of the program, exactly as it ran:

Integer (50 > 30): true
Integer (50 > 60): false
String (Banana > Apple): true
String (Banana > Cherry): false

6.0
4.0
6.5
[0, 0]
[0, 0, 0]
[0]

Reading it in groups:

  • Lines 1–4: one bounded generic class, comparing integers numerically and strings alphabetically
  • Lines 6–8: one ? extends Number method summing a list of Integer, of Double, and of mixed Number
  • Lines 10–12: one ? super Integer method filling a list of Number, of Object, and of Integer

The Quiet Trap: A Raw Type Compiles With Only a Note

The source file has two commented-out lines worth looking at:

// ScoreBox<Integer> intBox = new ScoreBox(50);
// ScoreBox<String> strBox = new ScoreBox("Banana");

These leave out the <>. That makes new ScoreBox(50) a raw type: the generic class used with no type argument at all, as if generics didn't exist. When I uncommented them, the program didn't fail to compile. It printed a note and kept going:

Note: java_12Generics.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.

In the lesson’s own lines, the values happen to match the declared type, so nothing goes wrong. But that’s luck, not checking. I tested what happens when they don’t match:

ScoreBox<Integer> box = new ScoreBox("Banana");
System.out.println("Compiled and constructed fine");
System.out.println(box.isHigherThan(30));

It compiled with the same two-line note, constructed the box, and then crashed:

Compiled and constructed fine
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String

A ScoreBox<Integer> was holding a String, and nothing complained until compareTo ran. The crash also happened far from the actual mistake: the bug is on the constructor line, but the stack trace points at isHigherThan.

That’s the quiet part, and it rhymes with yesterday’s lesson. Generics exist to turn a runtime ClassCastException into a compile-time error. A raw type switches that protection off, and the compiler's only response is a note that's easy to scroll past. The <> is two characters, and it's the difference between "the compiler checked this" and "the compiler hoped."

Key Takeaway

Generics in Java are about telling the compiler enough that it can check your work:

  • A bound (T extends Comparable<T>) is what lets you call methods on T, because the compiler won't assume a method exists without one
  • Generics are invariant: a List<Integer> is not a List<Number>, which is why wildcards exist
  • ? extends T accepts subtypes and is for reading. ? super T accepts supertypes and is for writing. That's PECS
  • Using the wrong direction fails twice: at the caller and inside the method body

And like the last few lessons, the dangerous case is the one the compiler doesn’t stop. A raw type compiles with a note and fails at runtime, far from the line that caused it.

The Arbiter Journey Continues

One concept at a time, this series is building toward Arbiter, a microservices-based test management platform using Java, Spring Boot, databases, testing frameworks, Docker, and AWS. Generics show up in Arbiter almost immediately. Every Spring Data repository is generic, JpaRepository<TestCase, Long> and the like, so the type parameters from today are what make one repository interface work for test cases, test runs, and defects. API responses can share a single generic wrapper for paged results. And utilities that need to work across many result types, such as summing durations or collecting failures, are exactly where ? extends and ? super decide whether a helper is reusable or locked to one type.

Follow along with the complete Arbiter playlist

https://youtube.com/playlist?list=PLZG2gr4IjsZM&si=StFLpldNdIOjc3ub

GitHub Repository

You can follow the complete learning journey, source code, explanations, and future lessons here:

GitHub Repository: https://github.com/Someone-anon-coder/Arbiter

Follow the Complete Arbiter Series

Every Guide and Guide Theory article in this series, collected in one place:

Explore My Other Learning Series

If you’re interested in my other programming and technology learning journeys, you may also enjoy:

What’s Next?

In Day 13, we move into Java’s functional features: lambda expressions and the built-in functional interfaces (Function, Predicate, Consumer, Supplier). Those interfaces are generic all the way down, so today's bounds and wildcards show up again almost immediately.

Happy coding! ☕

Companion theory article: For the theoretical background behind this lesson, see the Day 12 Guide Theory piece, published alongside this one. For the previous lesson’s theory, see Day 11 Guide Theory.

--

Dev Genius
Dev Genius

Published in Dev Genius

Coding, Tutorials, News, UX, UI and much more related to development