Advanced
Modern Java Idioms & the Type System
The modern features aren't separate tricks. They combine into one design. Records (immutable data), sealed types (closed type sets) and pattern matching (consume by shape) give you data-oriented programming with compiler-checked exhaustiveness, and a default branch throws that check away. Optional is a return type for "no result", never a null field. An unmodifiable view isn't a copy, and var with a diamond infers Object. A synthesis of Core Libraries, Robust OOP and Functional Java, shown with verified output.
Suggest an editModern Java Idioms & the Type System, Holistically
You've met the modern features one at a time:
This lesson shows that they're not a grab-bag of tricks, but a coherent design:
record+sealed+ pattern matching give you data-oriented programming: model your data as a closed set of immutable shapes, and let the compiler force you to handle every one.Optionalpushesnullout of your domain, at the edges.- Immutability makes objects safe to share, and
varremoves redundant noise.
The thesis is composition. Each feature is good on its own, but the real power lies in how they fit together into code that's concise, safe, and checked.
💡 The core idea.
- The modern features aren't a grab-bag — they compose into one coherent design.
record+sealed+ pattern matching give data-oriented programming with compiler-checked exhaustiveness.Optionalpushesnullout at the edges; immutability makes objects safe to share;varcuts noise.- The real power is composition — how the features fit together.
This lesson draws on the chapters Core Libraries and Robust OOP, and on Functional Java & the Streams API. Every output below was produced by compiling and running the code.
You'll be able to: predict whether a pattern switch over a sealed type still compiles after a new case is added, and explain why a default branch hides the new case; choose Optional as a return type, and predict what a null Optional field and a failing map do; choose between an unmodifiable view and a copy, and predict what var infers from a diamond; write a program that composes records, a sealed interface, a pattern switch, a stream and Optional.
📘 How to read the Intuition boxes. Each one is built in three moves:
- The mechanism — what the compiler and the JVM do.
- A concrete bite — a specific, runnable failure (often a real compiler error), shown so the trap is visible.
- The earned rule — the decision heuristic, now justified rather than asserted, plus its cost.
Table of contents
- Data-oriented design: records + sealed + patterns
Optionalovernull- Immutability and
var - Composing it all
- Mental-model summary
- Gotcha checklist
- Check yourself
- Sources
1. Data-oriented design: records + sealed + patterns
These three features lock together:
- a
sealedinterface defines a closed set of cases; - each case is an immutable
record; - a pattern
switchconsumes them, destructuring their components. Because the set is closed, the compiler checks it for exhaustiveness, with nodefaultneeded.
A plain class that implements a sealed interface must say final, sealed or non-sealed; otherwise, javac reports sealed, non-sealed or final modifiers expected. A record needs none of the three, because a record class is implicitly final [2].
Output:
Circle -> 12.57
Rectangle -> 12.00
Triangle -> 24.00Analysis.
- Each shape is a one-line
record, with privatefinalfields, accessors, and generatedequals/hashCode/toString[1]. sealeddeclares the complete set.- The pattern
switchdestructures each shape (Circle(double r)) to compute its area, with nodefault[5] [6].
The data lives in the records, while the behavior lives in the switch, outside the data. And the type system guarantees that every case is handled. That's data-oriented programming: a closed algebra of data, with operations added from outside.
Intuition.
Mechanism. sealed gives the compiler the full list of subtypes. A switch whose cases cover every permitted subtype is exhaustive [3], and a switch expression that isn't exhaustive is a compile-time error [4].
Concrete bite. The payoff is that the compiler becomes your checklist. Add a fourth permitted shape, Pentagon, and leave area alone:
Compiler error:
Main.java:9: error: the switch expression does not cover all possible input values
return switch (s) {
^
1 errorEvery such switch in the program stops compiling until it handles Pentagon. You first met this error in Sealed Classes & Pattern Matching.
Non-example: a default over a sealed type. A default makes any switch exhaustive [3] — which also silences the check above:
Output:
Circle -> 12.57
Pentagon -> 0.00
Pentagon compiled, and its area is silently wrongThe default caught Pentagon, so a pentagon with side 3 got an area of 0.00. The compile error would have pointed you straight at this switch; instead, the default turned it into a wrong number at run time.
Compare this with the inheritance alternative, an abstract double area() in each subclass:
- use polymorphism when behavior travels with open subtypes;
- use sealed types + patterns when the type set is closed, and operations get added from outside.
💡 Earned rule. Reach for record + sealed + pattern switch to model closed sets of structured data — results, events, AST nodes, protocol messages — and process them exhaustively. Leave out the default, so that a new case is a compile error rather than a silent wrong answer.
- The cost: choosing this style over class polymorphism, and maintaining the
permitslist (which the compiler enforces). - The payoff: concise, immutable data, plus operations that the compiler proves complete. A whole class of "forgot a case" bugs disappears.
2. Optional over null
Its own inventor, Tony Hoare, called null his "billion dollar mistake" [12]. It's invisible in the type, and fatal when dereferenced (References, Equality & the Object Model). The idiom is to keep null out of your domain entirely:
- wrap a maybe-absent value in
Optionalat the boundary; - chain
map/filter/orElseto handle absence declaratively.
Output:
LOCALHOST
us-east
8080Analysis. lookup wraps the possibly-null result of map.get in Optional.ofNullable. Absence becomes a typed Optional.empty(), never a raw null escaping into the program. The callers can then compose:
hostwas present, somap(String::toUpperCase)ran, andorElsewas skipped;regionwas absent, somapwas skipped, andorElse("us-east")supplied the default;portwas present, and got parsed.
There's no if (x != null), and no NullPointerException: the chain handles absence for you.
Intuition.
Mechanism. Optional makes "might be absent" part of the type. An Optional<String> isn't a String, so the compiler makes you unwrap it before use. map transforms the value only if one is present, and orElse/orElseGet supply defaults. The unsafe unwrap — get() on an empty Optional — throws NoSuchElementException (as Functional Java & the Streams API shows).
Concrete bite. Optional is meant as a return type. Its API says it's "primarily intended for use as a method return type where there is a clear need to represent 'no result'" [7]. The same note adds that a variable of type Optional "should never itself be null".
Non-example: an Optional field left null. A field of type Optional starts out as null, just like any other reference field:
Output (prints display:, then a thrown exception):
display:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.util.Optional.orElse(Object)" because "this.nickname" is nullThe code looks null-safe, yet it crashed with the very exception that Optional exists to prevent — the worst of both worlds. For a field, use a plain nullable reference or a real default, and return Optional from the getter instead.
Edge: Optional handles absence, not failure. orElse covers only the empty case. If the function inside map throws, the exception passes straight through:
Output (prints parsing..., then a thrown exception):
parsing...
Exception in thread "main" java.lang.NumberFormatException: For input string: "abc""abc" was present, so map called Integer.parseInt("abc"), which threw — and orElse(80) never ran. Validate the text first, or catch the exception inside the mapping function.
💡 Earned rule. Return Optional<T> from methods that may find nothing. Build it with ofNullable at the boundaries, and consume it with map/filter/orElse. Keep null out of your domain logic.
- The cost: wrapping and unwrapping, plus discipline about where
Optionalbelongs (in return types, not fields). - The payoff: "absent" becomes a typed, composable case, instead of a run-time crash.
3. Immutability and var
Modern Java leans heavily on immutable objects: records and final fields. An object that can't change is safe to share, to cache, and to use as a key. It needs no defensive copies, and gives you no aliasing surprises. Meanwhile, var removes redundant type noise wherever the right-hand side already states the type.
Output:
Point[x=0, y=0]
Point[x=3, y=4]
2Analysis.
Pointis immutable, sotranslatedoesn't mutate anything. Instead, it returns a newPoint:originis untouched (Point[x=0, y=0]), andmovedholds the new value (Point[x=3, y=4]).- This "transform by producing a new value" approach is the immutable idiom, just like a
Stringoperation. varinferred the types —Point,Point,List<String>— from the initializers. The static type is still there; you just didn't write it.
var changes nothing about scope. A var local lives in its block like any other local (Methods, §3), and it shadows a field by the same rules (Classes & Objects).
Intuition.
Mechanism. An immutable object's state is fixed at construction, and "changes" allocate a new object. Since nothing can change it behind your back, aliases are harmless. A record's fields are private final [1], and final fields are what make an object safe to hand between threads [13] (The Java Memory Model & Performance). var is compile-time inference: the variable has a concrete type, which you simply didn't spell out [11].
Concrete bite: final is shallow. A record's final field fixes the reference, not the object it points to. So a record holding a caller's ArrayList changes whenever the caller changes the list. Enums & Records shows this, along with the fix: List.copyOf in a compact constructor.
Non-example: an unmodifiable view is not a copy. Collections.unmodifiableList looks like the same fix, but it returns a view, and reads "read through" to the original list [8]. List.copyOf, by contrast, returns an unmodifiable list that "will not reflect" later changes [9]:
Output:
view: [Ada, Linus]
copy: [Ada]
view.add: UnsupportedOperationExceptionNobody can change the list through view — add threw. But source still could, and view showed Linus. The copy, meanwhile, kept [Ada].
Non-example: var with a diamond. var copies its type from the initializer, and new ArrayList<>() with nothing to infer from gives ArrayList<Object>. OpenJDK's style guide calls this "DANGEROUS" [10]:
Compiler error:
Main.java:7: error: incompatible types: Object cannot be converted to String
String first = names.get(0);
^
1 errornames is an ArrayList<Object>, so get(0) returns an Object. The fix is to write the type argument: var names = new ArrayList<String>();.
💡 Earned rule. Default to immutability for data you share or use as keys: records, final fields, transform-by-new, and List.copyOf for any list you keep. Use var where the initializer makes the type obvious — and never with a bare diamond.
- The cost: extra allocations and verbose deep updates (for immutability), and hidden types if you overuse
var. - The payoff: data that's safe to share without defensive copies, and code with less ceremony.
4. Composing it all
The features earn their keep together. Here, a small payments domain uses:
sealed+records for the data;- a pattern
switchfor behavior; - a stream to aggregate;
Optionalfor a query that might find nothing.
Output:
total fees: 7.50
biggest: CardAnalysis. That's five features in one short program:
sealed Payment, a closed set ofrecords (immutable data);- a pattern
switchthat computes each payment'sfee: cash is free, and card is 3%, so6.00 + 1.50 = 7.50; stream().mapToDouble(...).sum(), which aggregates the fees;stream().max(...), which returns anOptional<Payment>, consumed withmap(...).orElse("none").
The compiler checks that both switches are exhaustive, and null is nowhere in sight. amountOf exists only because Payment declares no methods; the ✅ section shows how a record's accessor can replace it.
Intuition. Mechanism. The composition works because the features share a common philosophy:
- make data explicit and immutable (records);
- make the set of cases closed and checkable (sealed);
- make operations declarative (patterns, streams);
- make absence a type (
Optional).
Concrete bite. This is data-oriented Java: declarative, like the stream pipelines from earlier in this chapter, with the compiler enforcing exhaustiveness and types throughout. The trade-off is knowing when to use it. This style shines for data processing, and for modeling closed domains. Classic OOP, with encapsulated mutable objects and polymorphic behavior, is still the better fit for stateful, open-ended designs.
💡 Earned rule. Compose the modern features when you model and transform data: sealed records for data, pattern switches and streams for operations, and Optional for absence. Reach for classic object-oriented design (mutable state, inheritance) when behavior and identity dominate.
- The cost: the judgment to know which paradigm fits.
- The payoff: for the large class of data-shaped problems, modern Java is concise, immutable, and compiler-verified from end to end.
5. Mental-model summary
| Principle | Consequence |
|---|---|
record + sealed + pattern switch = data-oriented design |
Immutable data, a closed case set, and compiler-checked exhaustive operations |
| Exhaustiveness over a sealed type is compiler-enforced | Add a case and every non-exhaustive switch stops compiling — a built-in checklist |
A default makes any switch exhaustive |
Over a sealed type it swallows a new case at run time; leave it out |
Optional makes absence a type; keep null out of the domain |
ofNullable at boundaries; map/orElse to handle absence; a return type, not a field |
orElse covers absence, not failure |
An exception inside map passes straight through |
Immutability makes objects safe to share; final is shallow |
Transform-by-new; List.copyOf for kept lists; an unmodifiable view still shows the source's changes |
var copies the initializer's type |
var where the type is obvious; new ArrayList<>() gives ArrayList<Object> |
| The features compose by shared philosophy | Data-oriented Java is concise and checked; classic OOP fits stateful, open designs |
6. Gotcha checklist
| Symptom | Likely cause | Fix |
|---|---|---|
the switch expression does not cover all possible input values |
a new permitted subtype has no case |
add the case; don't add a default |
A new subtype compiles, but its result is wrong (0.00) |
a default branch over a sealed type caught it |
remove the default, so the compiler lists the missing case |
A pattern switch needs a default you don't want |
the selector's type is open | model the cases as a sealed type |
NullPointerException: Cannot invoke "java.util.Optional.orElse(Object)" |
an Optional field or variable holds null |
never store null in an Optional; use Optional as a return type |
NoSuchElementException: No value present |
get() on an empty Optional |
orElse, orElseGet or orElseThrow |
NumberFormatException despite orElse(...) |
the function inside map threw; orElse covers only empty |
validate, or catch inside the mapping function |
| An "unmodifiable" list changed | Collections.unmodifiableList is a view of a list someone else changed |
keep List.copyOf(...) instead |
UnsupportedOperationException on add |
the list is unmodifiable (List.of, List.copyOf, a view) |
copy it: new ArrayList<>(list) |
incompatible types: Object cannot be converted to String after var x = new ArrayList<>() |
var with a bare diamond infers ArrayList<Object> |
write the type argument: new ArrayList<String>() |
| A "modified" record didn't change | records are immutable; translate-style methods return a new record |
capture the result |
| A record's list changed after construction | final fixes the reference, not the list |
List.copyOf in a compact constructor |
✅ Check yourself
One check per objective. Answer before you open anything.
In §4, how many payments are cards? Count them with a stream and instanceof. Then make amountOf unnecessary.
Two. Now declare double amount(); in Payment. Each record already has a public accessor amount() with that exact name and return type [1], so it implements the interface method with no extra code. Payment::amount can then replace amountOf:
Output:
cards: 2
total: 350.0📚 Sources
- The Java Language Specification, Java SE 25, §8.10.3 "Record Members" — https://docs.oracle.com/javase/specs/jls/se25/html/jls-8.html#jls-8.10.3
- The Java Language Specification, Java SE 25, §8.1.1.2 "
sealed,non-sealed, andfinalClasses" ("a record class is implicitlyfinal") — https://docs.oracle.com/javase/specs/jls/se25/html/jls-8.html#jls-8.1.1.2 - The Java Language Specification, Java SE 25, §14.11.1.1 "Exhaustive Switch Blocks" — https://docs.oracle.com/javase/specs/jls/se25/html/jls-14.html#jls-14.11.1.1
- The Java Language Specification, Java SE 25, §15.28.1 "The Switch Block of a
switchExpression" ("It is a compile-time error if aswitchexpression is not exhaustive") — https://docs.oracle.com/javase/specs/jls/se25/html/jls-15.html#jls-15.28.1 - JEP 440: Record Patterns (JDK 21) — https://openjdk.org/jeps/440
- JEP 441: Pattern Matching for
switch(JDK 21) — https://openjdk.org/jeps/441 java.util.Optional, Java SE 25 API (class API note) — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/Optional.htmljava.util.Collections.unmodifiableList, Java SE 25 API ("unmodifiable view"; queries "read through") — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/Collections.html#unmodifiableList(java.util.List)java.util.List.copyOf, Java SE 25 API ("will not reflect such modifications") — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/List.html#copyOf(java.util.Collection)- OpenJDK, Local Variable Type Inference: Style Guidelines, G6 "Take care when using var with diamond or generic methods" — https://openjdk.org/projects/amber/guides/lvti-style-guide
- JEP 286: Local-Variable Type Inference (JDK 10) — https://openjdk.org/jeps/286
- Tony Hoare, "Null References: The Billion Dollar Mistake", QCon 2009 (InfoQ) — https://www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare/
- The Java Language Specification, Java SE 25, §17.5 "
finalField Semantics" — https://docs.oracle.com/javase/specs/jls/se25/html/jls-17.html#jls-17.5
🧪 Predict, then check.
- Add a
Pentagon(double side)to the §1Shapehierarchy, and predict what the compiler says aboutareauntil you add its case. - Predict what §2 prints if
configheld"port" -> "abc"and you parsed it with.map(Integer::parseInt). WouldorElse(80)save you? - Extend §4 to print the count of
Cardpayments, using a streamfilterwithp instanceof Card, and predict the result.
§1's bite, §2's edge and the ✅ <details> answer each one in turn.
Your Turn
Before you move on, check your understanding with the coach — explain the idea, apply it, weigh the trade-offs, then defend your reasoning.