Modern Java: Records, Sealed Classes, Pattern Matching
What's new in Java 16-21. The features that made the language feel current again. Records, sealed types, pattern matching, var, text blocks.
Java woke up
For about a decade — roughly 2004 to 2014 — Java felt stuck. Versions came out, but the language barely changed. C# pulled ahead with LINQ, properties, async/await. Kotlin and Scala carved out the "modern JVM language" niche. Java's reputation as verbose, boilerplate-heavy, slow to adopt new ideas became conventional wisdom.
Java 8 (2014) was a turning point — lambdas, streams, the Optional type. But the major modernization happened in 2017-2024, with Java's switch to a six-month release cadence. Every six months, new features. Some are small (String.repeat, Stream.toList). Some are transformative.
This chapter covers the transformative ones — records, sealed classes, pattern matching, and the smaller additions that together make Java 21 feel like a different language than Java 8. If you've been writing Java the same way since 2014, this chapter is the catch-up.
We've touched on most of these in earlier chapters. This is the consolidated tour.
Records (Java 16+)
Records are immutable data classes. They eliminate the constructor/equals/hashCode/toString/getters boilerplate that bloated Java for decades.
public record User(String name, int age, String email) {}
That single line gives you:
- Final fields name, age, email
- A canonical constructor
- Accessor methods name(), age(), email()
- equals based on field values
- hashCode based on field values
- A useful toString: User[name=Alice, age=30, email=a@x.io]
**Validation in the compact constructor:**
public record User(String name, int age, String email) {
public User {
if (age < 0) throw new IllegalArgumentException("age must be >= 0");
if (email == null) throw new NullPointerException("email");
name = name.trim(); // allowed: reassign params before fields are set
}
}
**Adding methods:**
public record User(String name, int age, String email) {
public boolean isAdult() {
return age >= 18;
}
public String displayName() {
return name + " <" + email + ">";
}
}
**Records can implement interfaces** but cannot extend classes (they implicitly extend Record).
**When to use records:**
- DTOs and API response objects
- Value objects (Money, Coordinates, EventID)
- Tuples for returning multiple values
- Configuration objects
- Anything where "this is a bundle of values" describes the type
**When NOT to use records:**
- Mutable entities (records are immutable by design)
- JPA entities (often need mutability and inheritance, both of which records forbid)
- Classes with rich behaviour and identity beyond their values
For pure data classes, records have made manual equals/hashCode writing obsolete. Use them widely.
Sealed classes (Java 17+)
Sealed classes let you control which other classes can extend or implement them. They enable real algebraic data types in Java.
public sealed interface Shape permits Circle, Square, Triangle {}
public record Circle(double radius) implements Shape {}
public record Square(double side) implements Shape {}
public record Triangle(double base, double height) implements Shape {}
Now Shape has exactly three implementations. Nothing else in the codebase can implement Shape (unless explicitly listed). The compiler enforces this at compile time.
**Why this matters:** combined with pattern matching for switch, you get exhaustive case handling:
double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square sq -> sq.side() * sq.side();
case Triangle t -> 0.5 * t.base() * t.height();
// No default needed — the compiler knows these are all the cases
};
}
Add a fourth shape (Rectangle) to the sealed permit list, and every switch becomes a compile error until you add the case. The compiler enforces completeness — a feature usually associated with Haskell, ML, Rust, or Kotlin's sealed classes.
**Three ways to "permit" subtypes:**
public sealed interface Result<T>
permits Success, Failure {} // explicit list
// Or, in the same file, no permits clause needed:
sealed interface Result<T> {}
record Success<T>(T value) implements Result<T> {}
record Failure<T>(String error) implements Result<T> {}
**Subtypes must declare themselves** one of:
- final — no further extension allowed
- sealed — only specific subtypes can extend
- non-sealed — unrestricted extension allowed (opt back into open inheritance)
public sealed interface Animal permits Dog, Cat, Bird {}
public final class Dog implements Animal {}
public final class Cat implements Animal {}
public non-sealed class Bird implements Animal {} // any class can extend Bird
Use sealed types for closed sets of variants — operation types in an interpreter, message types in a state machine, result types (Success/Failure/Pending).
Pattern matching for switch (Java 21+)
Combined with sealed types and records, pattern matching transforms switch from a primitive into a powerful destructuring tool.
**Type patterns:**
String describe(Object obj) {
return switch (obj) {
case Integer i -> "integer: " + i;
case String s when s.isEmpty() -> "empty string"; // with guard
case String s -> "non-empty string of length " + s.length();
case List<?> l -> "list with " + l.size() + " elements";
case null -> "null";
default -> "something else";
};
}
The case Integer i pattern checks the type AND binds the matched value. The when clause adds an extra condition.
**Record patterns** (Java 21+) destructure records:
record Point(int x, int y) {}
String describe(Object obj) {
return switch (obj) {
case Point(int x, int y) when x == y -> "diagonal point at " + x;
case Point(int x, int y) -> "point (" + x + ", " + y + ")";
default -> "not a point";
};
}
Combined with sealed types:
sealed interface Expr permits Lit, Add, Mul {}
record Lit(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
int eval(Expr e) {
return switch (e) {
case Lit(int v) -> v;
case Add(Expr l, Expr r) -> eval(l) + eval(r);
case Mul(Expr l, Expr r) -> eval(l) * eval(r);
};
}
No default branch needed — the compiler knows the three subtypes of Expr cover all cases.
This pattern — sealed type + records + pattern-matching switch — replaces visitor pattern in many cases. It's clearer, more concise, and exhaustively checked.
**Pattern matching for instanceof** (Java 16+) is the smaller cousin:
if (obj instanceof String s) {
System.out.println(s.length()); // s is in scope, already cast
}
Use it everywhere you used to write if (obj instanceof X) { X x = (X) obj; ... }.
var, text blocks, other QoL improvements
Smaller changes that add up.
**var for local type inference** (Java 10+):
var users = userRepo.findActive(); // List<User> inferred
var groups = users.stream()
.collect(Collectors.groupingBy(User::role)); // Map<Role, List<User>>
Use when the type is obvious from the right-hand side. Don't use when the type would actually help the reader.
**Text blocks** (Java 15+):
String sql = """
SELECT id, name, email
FROM users
WHERE created_at > ?
AND status = 'active'
""";
Multi-line strings without escape hell. Preserves indentation up to the closing """.
**String.formatted** (Java 15+):
String s = "Hello, %s! You are %d.".formatted(name, age);
Instance method form of String.format.
**Stream.toList()** (Java 16+):
Replaces .collect(Collectors.toList()) for the common case. Returns an unmodifiable list:
List<String> names = users.stream().map(User::name).toList();
**Helpful NullPointerException** (Java 14+):
Before:
Exception: NullPointerException
at com.example.App.handle(App.java:42)
Now:
Exception: Cannot invoke "User.getName()" because "user" is null
at com.example.App.handle(App.java:42)
The JVM tells you exactly which dereference was null. Massive debugging win.
**Switch expressions** (Java 14+):
Already covered in the Control Flow chapter. Use -> form and yield when needed.
**Enhanced random** (Java 17+):
RandomGenerator and modern algorithms in java.util.random. The legacy Random and ThreadLocalRandom still work but new code can pick better algorithms (Xoshiro, L64X128, etc.) for better statistical properties.
Virtual threads (Java 21+)
Worth a brief revisit from the concurrency chapter — this is the biggest runtime improvement in modern Java.
Traditional threads (platform threads) are mapped 1:1 to OS threads. They're heavy. A typical JVM can run thousands. Each one takes ~1MB of stack.
**Virtual threads** are lightweight. Millions can run on a JVM. They're scheduled by the JVM onto a small pool of platform threads. When a virtual thread blocks on I/O, it parks — its platform thread is freed to run another virtual thread. The blocking code looks normal, but performs like async.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return processItem(i);
});
});
}
That runs 10,000 concurrent virtual threads. Each sleeps for a second. The whole batch completes in about a second, using a tiny number of platform threads. Equivalent code with platform threads would crash with thread-creation limits.
**What this enables:**
- Write blocking, sequential-looking code that performs like async.
- Drop callback hell, CompletableFuture chains, reactive streams — for many use cases.
- Use one virtual thread per HTTP request, even for high-traffic servers.
**Spring Boot 3+, Quarkus, and other frameworks** have first-class virtual thread support. Enable it in config; existing code suddenly scales differently.
**Caveats:**
- Synchronized blocks pin a virtual thread to its carrier (Java 21). Use ReentrantLock instead for hot paths. Java 23+ fixed this for many cases.
- CPU-heavy work doesn't benefit. Virtual threads help with I/O-bound concurrency, not parallel computation.
- Native method calls still block the carrier thread.
For new server-side Java code in 2026, virtual threads are typically the right choice. They eliminate one of the longest-running awkwardnesses in Java backend development.
Looking forward
A few features in active development worth watching.
**String templates.** True interpolation like STR."Hello \{name}". Status as of 2026: still preview after being yanked from final release. Eventually it'll happen.
**Value classes / Valhalla.** A long-term project to add true value types (objects stored inline without identity, faster than current heap-allocated objects). Could be transformative for numeric and primitive-heavy code. Years away from final.
**Stream gatherers.** Custom intermediate operations on streams. Preview in Java 22. Lets you write your own filter/map-style operators.
**Foreign Function & Memory API (Java 22+).** Replaces JNI for calling native code. Cleaner, safer, faster.
**Pattern matching enhancements.** Deconstruction patterns continue to expand. Eventually Java's pattern matching may rival what Scala or Rust offer.
The pace of change is steady. Java 8 (2014) and Java 21 (2023) feel like very different languages. Java in 2030 will likely look noticeably different from 2026, but the core stability that made Java successful — backward compatibility, the JVM, the standard library — remains intact. You can write Java today knowing it'll still work in ten years AND that you'll have new features to gradually adopt.
That's a rare combination. It's why Java is still here, and still worth learning, in 2026 and beyond.
Series recap
If you started this series at chapter 1 and made it here, you've covered:
**Foundation (1-4):** What Java is, the JVM/JRE/JDK distinction, variables and memory, Strings in depth.
**OOP and structure (5-9):** Classes, the four pillars, abstract vs interfaces, the Object class, access modifiers and packages.
**Reliability and data (10-14):** Exception handling, generics, the collections framework, control flow, enums.
**Modern Java (15-20):** Inner classes, lambdas, streams, concurrency, build tools, and the recent features.
That's 20 chapters across roughly 60,000 words of carefully-written Java content. Not exhaustive — there's no chapter on JDBC, no deep dive on annotations, no detailed I/O. But the foundation is here. You can read any Java code, recognise the patterns, and reach for the right tool.
What to learn next:
- **Spring Boot.** The dominant Java web framework. Annotations, dependency injection, the autoconfiguration model.
- **JPA and Hibernate.** Mapping Java objects to relational databases. ORMs are powerful and dangerous; learn the patterns.
- **Reactive streams** (Reactor, RxJava). If you're not on virtual threads yet, reactive is the alternative for high-concurrency.
- **Testing.** JUnit 5, Mockito, AssertJ. Testing patterns. Test-driven design.
- **Build optimisation, profiling, and tuning.** Once your app is real, you'll need to make it fast.
- **Specific domains.** Microservices, batch processing, real-time, machine learning — Java touches all of them.
Go build something. The best way to learn Java is to write Java.
⁂ Back to all modules