Level Up with Java 21: Virtual Threads, Pattern Matching & More

~
~
Published on
Authors
java-21-record-patterns-banner

Java 21 Record Patterns: Clean, Safe Destructuring (JEP 440)

Why record patterns?

Before Java 21, you’d often write code that looked like a ritual:

  • instanceof → cast → getters → local vars → finally do work
  • Repeat the same steps for every variant of a type hierarchy

It’s not just verbose—it’s easy to drift into inconsistency:

  • One branch forgets a null check
  • Another branch extracts fields in a different order
  • Another branch casts and hopes you don’t refactor the type later

Record patterns remove that dance. You match the shape and unpack the data right where you branch.

Quick refresher: records

Records are immutable data carriers: they package data with a small, predictable API.

public record Point(int x, int y) {}
public record Segment(Point start, Point end) {}

A record’s components (x, y, start, end) are exactly what you’ll destructure with record patterns.

The simplest win: destructure in instanceof

Here’s the “aha” moment:

Object o = new Point(3, 4);

if (o instanceof Point(int x, int y)) {
    System.out.println(x + y); // 7
}

No casting. No getters. No temporary variables just to move values around. You get names you can use, right where the logic happens.

Nested record patterns: destructure deeply, still readable

Record patterns can nest—so you can destructure complex data structures without writing a pile of intermediate code.

Object o = new Segment(new Point(0, 0), new Point(10, 10));

if (o instanceof Segment(Point(int sx, int sy), Point(int ex, int ey))) {
    System.out.println("Δx=" + (ex - sx) + ", Δy=" + (ey - sy));
}

You can bind as deep as your records go.

Practical use cases for nesting

  • Coordinates in shapes (Rectangle(Point topLeft, Point bottomRight))
  • API responses (Response(Meta meta, Data data))
  • Domain commands/events (OrderPlaced(OrderId id, Customer customer, Money total))

The rule of thumb: nest until it stops being clear. If the pattern becomes hard to read, you can bind a larger chunk and unpack in a helper method.

Switch with record patterns (and guards)

This is where record patterns really shine: decision logic that reads like a spec.

With Java 21, pattern matching for switch plus record patterns is a strong combo.

sealed interface Shape permits Circle, Rectangle, Square {}

record Circle(double r) implements Shape {}
record Rectangle(double w, double h) implements Shape {}
record Square(double a) implements Shape {}

static double area(Shape s) {
    return switch (s) {
        case Circle(double r) -> Math.PI * r * r;
        case Rectangle(double w, double h) -> w * h;
        // Guard (when) works with patterns:
        case Square(double a) when a >= 0 -> a * a;
    };
}

What’s happening here?

  • Each case both checks the type and extracts the components
  • Guards (when) let you keep small conditions near the matching logic
  • With sealed types, the compiler can help ensure you don’t forget cases

This style tends to reduce “logic drift” because the data you’re branching on is visible at the branch.

Real-world example: routing domain events

This is a classic place where casts and getters pile up: event handling.

sealed interface Event permits UserCreated, PasswordReset {}

record UserCreated(String id, String email) implements Event {}
record PasswordReset(String id, String token) implements Event {}

void handle(Event e) {
    switch (e) {
        case UserCreated(String id, String email) -> sendWelcome(id, email);
        case PasswordReset(String id, String token) -> sendReset(id, token);
    }
}

Why this is a big deal

  • No fragile casts
  • No manual unpacking
  • Branches are short and clear
  • With sealed, the compiler can warn you when a new event type is added but not handled

That last point is huge for seniors: it turns some categories of “forgotten edge case” into compile-time feedback.

Tips, traps, and best practices

1) Name things well

Pattern variable names are your docs.

✅ Prefer:

case UserCreated(String userId, String email) -> ...

🚫 Avoid:

case UserCreated(String id1, String s) -> ...

2) Use guards (when) sparingly

Guards are powerful, but they can hide complex logic if overused.

✅ Keep guards simple:

case Square(double a) when a >= 0 -> a * a;

If a guard becomes “real logic,” consider extracting it:

case Square(double a) when isValidSide(a) -> ...

3) Let the compiler help (sealed types)

Sealed hierarchies plus switch can give you exhaustiveness checking. That means:

  • fewer “default” cases hiding missing logic
  • more confidence during refactors

4) Don’t over-nest

Deep nesting can become a puzzle for readers.

If it starts feeling dense:

  • bind a mid-level record to a variable
  • extract a helper function
  • aim for “readable at a glance”

5) Records + patterns love immutability

This is a design win: records encourage immutable data. Patterns make immutable data pleasant to work with. Together they nudge your code toward clarity and safer modeling.

Migration micro-guide (try this today)

Step 1: Replace manual unpacking

Find a hotspot like this:

  • instanceof
  • cast
  • getters
  • a lot of repeated glue code

Replace with a single pattern:

if (x instanceof RecordType(var a, var b)) {
    ...
}

Step 2: Convert long if/else chains into switch

If the code is selecting behavior based on type, switch with patterns tends to read cleaner than a long chain.

Step 3: Seal your domain hierarchies (where it fits)

If you have a known set of variants (events, commands, shapes, states), consider sealing the interface to unlock compiler support.

Cheat sheet

  • T(var1, var2) binds record components to new locals
  • Works in instanceof tests and switch labels
  • Supports nesting and guards (when)
  • Plays best with records, sealed types, and pattern-matching switch

Closing thought

Record patterns are about clarity and correctness: you write less code, and the compiler guarantees more.

If your codebase still has “cast + getters” decision trees, pick one today and refactor it with record patterns—you’ll feel the difference immediately.