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
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 forIntegerandString - Bounded type parameters:
T extends Comparable<T>and whycompareToneeds 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): falseThe 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.5This 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.
? 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.
sumNumbersreads from its list. The list produces values for the method, so it uses? extends.fillWithZerowrites 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 NumberThe 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 IntegerSame 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 Numbermethod summing a list ofInteger, ofDouble, and of mixedNumber - Lines 10–12: one
? super Integermethod filling a list ofNumber, ofObject, and ofInteger
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.StringA 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 onT, because the compiler won't assume a method exists without one - Generics are invariant: a
List<Integer>is not aList<Number>, which is why wildcards exist ? extends Taccepts subtypes and is for reading.? super Taccepts 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:
- Arbiter Guide https://computer-info-1.medium.com/list/arbiter-java-20daa21849c8
- Arbiter Guide Theory https://computer-info-1.medium.com/list/arbiter-java-theory-13ecf0e21149
Explore My Other Learning Series
If you’re interested in my other programming and technology learning journeys, you may also enjoy:
- Cybersecurity https://computer-info-1.medium.com/list/cybersecurity-cba13dd0be16
- Python Guide https://computer-info-1.medium.com/list/python-guide-8e0b3bcab940
- Python Guide Theory https://computer-info-1.medium.com/list/python-guide-theory-81528784ebfe
- GoLang Guide https://computer-info-1.medium.com/list/golang-guide-e70e25ca8b42
- GoLang Guide Theory https://computer-info-1.medium.com/list/golang-guide-theoretical-082d8e7624ab
- Matplotlib https://computer-info-1.medium.com/list/my-progress-matplotlib-669052e8da38
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.









