Back to Sealed Classes & Interfaces
Q1Question 1 of 12

What are Record Patterns and pattern matching for switch (Java 21), and how do they enable exhaustive, type-safe deconstruction of sealed hierarchies?

Key Concepts
  • Record patterns (Java 21) match a record and bind its components in one step: `case Transfer(var from, var to, var amount)`.
  • Pattern-matching `switch` tests the runtime type of a selector and binds a variable per case, replacing `instanceof` chains.
  • Guarded patterns with `when` add business-rule conditions within a case.
  • Over a sealed hierarchy, the compiler verifies the switch is exhaustive: every permitted case must be handled.
  • Adding a new permitted subtype breaks every exhaustive switch at compile time.
  • Avoid `default` over a sealed type because it silently swallows newly added subtypes.
  • Nested record patterns destructure records inside records in a single expression.

Definition

Record patterns deconstruct records in a case, pattern-matching switch binds by runtime type with guards, and over a sealed hierarchy the compiler enforces exhaustiveness so adding a case becomes a compile error you cannot miss.
  • Record patterns – standardized in Java 21, match a record and bind its components: case Transfer(String from, String to, BigDecimal amount).
  • Type patterns – in a switch, test the runtime type and bind a variable: case TransferEvent t.
  • Guarded patterns – refine a case with a boolean condition using when: case Transfer t when t.amount() > LIMIT.
  • Exhaustive switch – over a sealed type, the compiler verifies every permitted case is handled without a default.
  • Nested patterns – destructure records inside records in one expression.
  • Null handling – explicit case null instead of silent NullPointerException.

Imagine National Bank of India has a fixed, official list of exactly the kinds of payment events that can occur: a transfer, a deposit, and a withdrawal, and nothing else is allowed (the sealed hierarchy). A pattern-matching switch is like a sorting clerk who looks at each incoming event, instantly recognizes which kind it is, and in the same motion opens it up and reads out its details (record pattern deconstruction). Because the list is closed, the supervisor guarantees the clerk has a procedure for every kind.

Understanding the Concepts

What is a type pattern in a switch?

A type pattern tests the selector's runtime type and binds a variable, replacing the old instanceof and cast chain.

JAVA
package com.bank.service;

import java.math.BigDecimal;

public class TypePatternDemo {

    sealed interface PaymentEvent permits TransferEvent, DepositEvent {
    }
    record TransferEvent(String from, String to, BigDecimal amount)
            implements PaymentEvent {}
    record DepositEvent(String account, BigDecimal amount)
            implements PaymentEvent {}

    public String describe(PaymentEvent event) {
        return switch (event) {
            case TransferEvent t -> "Transfer: " + t.amount() + " INR";
            case DepositEvent d -> "Deposit: " + d.amount() + " INR";
        };
    }

    public static void main(String[] args) {
        TypePatternDemo demo = new TypePatternDemo();
        System.out.println(demo.describe(
                new TransferEvent("SAV-1001", "SAV-2002", new BigDecimal("50000"))));
    }
}
TEXT
Transfer: 50000 INR

What is a record pattern?

A record pattern goes further and destructures the record's components into named bindings in the same case, so you immediately have the fields without calling accessors.

JAVA
package com.bank.service;

import java.math.BigDecimal;

public class RecordPatternDemo {

    sealed interface PaymentEvent permits TransferEvent, DepositEvent,
            WithdrawalEvent {}
    record TransferEvent(String from, String to, BigDecimal amount)
            implements PaymentEvent {}
    record DepositEvent(String account, BigDecimal amount)
            implements PaymentEvent {}
    record WithdrawalEvent(String account, BigDecimal amount)
            implements PaymentEvent {}

    public String process(PaymentEvent event) {
        return switch (event) {
            case TransferEvent(var from, var to, var amount) ->
                    "Transfer " + amount + " INR: " + from + " -> " + to;
            case DepositEvent(var account, var amount) ->
                    "Deposit " + amount + " INR to " + account;
            case WithdrawalEvent(var account, var amount) ->
                    "Withdraw " + amount + " INR from " + account;
        };
    }

    public static void main(String[] args) {
        RecordPatternDemo demo = new RecordPatternDemo();
        System.out.println(demo.process(
                new TransferEvent("SAV-1001", "SAV-2002", new BigDecimal("75000"))));
        System.out.println(demo.process(
                new DepositEvent("SAV-1001", new BigDecimal("25000"))));
        System.out.println(demo.process(
                new WithdrawalEvent("SAV-2002", new BigDecimal("10000"))));
    }
}
TEXT
Transfer 75000 INR: SAV-1001 -> SAV-2002
Deposit 25000 INR to SAV-1001
Withdraw 10000 INR from SAV-2002

How do guarded patterns work?

Guards refine a case with a boolean condition using the when keyword, so you branch on values, not just on shape.

JAVA
package com.bank.service;

import java.math.BigDecimal;

public class GuardedPatternDemo {

    sealed interface PaymentEvent permits TransferEvent, DepositEvent {}
    record TransferEvent(String from, String to, BigDecimal amount)
            implements PaymentEvent {}
    record DepositEvent(String account, BigDecimal amount)
            implements PaymentEvent {}

    private static final BigDecimal HIGH_VALUE = new BigDecimal("100000");

    public String classify(PaymentEvent event) {
        return switch (event) {
            case TransferEvent(var f, var t, var amount)
                    when amount.compareTo(HIGH_VALUE) > 0 ->
                    "HIGH VALUE transfer " + amount + " INR (needs approval)";
            case TransferEvent(var f, var t, var amount) ->
                    "Normal transfer " + amount + " INR";
            case DepositEvent(var account, var amount) ->
                    "Deposit " + amount + " INR";
        };
    }

    public static void main(String[] args) {
        GuardedPatternDemo demo = new GuardedPatternDemo();
        System.out.println(demo.classify(
                new TransferEvent("SAV-1001", "SAV-2002", new BigDecimal("250000"))));
        System.out.println(demo.classify(
                new TransferEvent("SAV-1001", "SAV-2002", new BigDecimal("5000"))));
    }
}
TEXT
HIGH VALUE transfer 250000 INR (needs approval)
Normal transfer 5000 INR

How does exhaustiveness work with sealed hierarchies?

If you declare a sealed interface that permits exactly three types, the compiler knows the complete set. A switch with a case for each is provably exhaustive and needs no default. If someone adds a fourth type, every non-exhaustive switch fails to compile.

JAVA
package com.bank.service;

import java.math.BigDecimal;

public class ExhaustiveDemo {

    sealed interface PaymentEvent permits TransferEvent, DepositEvent,
            WithdrawalEvent {}
    record TransferEvent(String from, String to, BigDecimal amount)
            implements PaymentEvent {}
    record DepositEvent(String account, BigDecimal amount)
            implements PaymentEvent {}
    record WithdrawalEvent(String account, BigDecimal amount)
            implements PaymentEvent {}

    public BigDecimal calculateFee(PaymentEvent event) {
        return switch (event) {
            case TransferEvent t -> new BigDecimal("25.00");
            case DepositEvent d -> BigDecimal.ZERO;
            case WithdrawalEvent w -> new BigDecimal("10.00");
            // No default: compiler proves this is exhaustive
        };
    }

    // If we add: record RefundEvent(...) implements PaymentEvent {}
    // This switch FAILS TO COMPILE until we handle RefundEvent
}

How do nested record patterns work?

When a record holds another record, you can destructure both in a single pattern.

JAVA
package com.bank.service;

import java.math.BigDecimal;

public class NestedPatternDemo {

    record AccountInfo(String id, BigDecimal balance) {}

    sealed interface Txn permits Transfer, Deposit {}
    record Transfer(AccountInfo from, AccountInfo to, BigDecimal amount)
            implements Txn {}
    record Deposit(AccountInfo account, BigDecimal amount)
            implements Txn {}

    public String process(Txn txn) {
        return switch (txn) {
            case Transfer(AccountInfo(var fromId, var fromBal),
                          AccountInfo(var toId, var toBal), var amount) ->
                    fromId + " (" + fromBal + ") -> " + toId
                            + " (" + toBal + "): " + amount + " INR";
            case Deposit(AccountInfo(var id, var bal), var amount) ->
                    "Deposit " + amount + " INR to " + id + " (" + bal + ")";
        };
    }

    public static void main(String[] args) {
        NestedPatternDemo demo = new NestedPatternDemo();
        System.out.println(demo.process(new Transfer(
                new AccountInfo("SAV-1001", new BigDecimal("500000")),
                new AccountInfo("SAV-2002", new BigDecimal("200000")),
                new BigDecimal("75000")
        )));
    }
}
TEXT
SAV-1001 (500000) -> SAV-2002 (200000): 75000 INR

How does null handling work?

A switch with patterns throws on null unless you add an explicit case null, which removes a common source of bugs.

JAVA
package com.bank.service;

import java.math.BigDecimal;

public class NullHandlingDemo {

    sealed interface PaymentEvent permits TransferEvent {}
    record TransferEvent(String from, String to, BigDecimal amount)
            implements PaymentEvent {}

    public String process(PaymentEvent event) {
        return switch (event) {
            case null -> "No event received";
            case TransferEvent(var f, var t, var amt) -> "Transfer: " + amt;
        };
    }

    public static void main(String[] args) {
        NullHandlingDemo demo = new NullHandlingDemo();
        System.out.println(demo.process(null));
        System.out.println(demo.process(
                new TransferEvent("SAV-1001", "SAV-2002", new BigDecimal("50000"))));
    }
}
TEXT
No event received
Transfer: 50000

Why should you avoid default over sealed types?

A default branch would silently swallow a newly added subtype, defeating the exhaustiveness guarantee. Without default, adding a new permitted type forces the compiler to flag every switch that does not handle it.

JAVA
// WRONG: default hides new subtypes
// return switch (event) {
//     case TransferEvent t -> ...;
//     default -> "Unknown"; // silently swallows new subtypes!
// };

// CORRECT: no default, compiler-enforced exhaustiveness
return switch (event) {
    case TransferEvent t -> "Transfer";
    case DepositEvent d -> "Deposit";
    case WithdrawalEvent w -> "Withdrawal";
};

What should a senior engineer know?

Pattern matching, record patterns, and sealed types together form Java's answer to algebraic data types. The compiler enforces case ordering: a more general pattern cannot precede a more specific one. Guards with when layer business logic without breaking exhaustiveness. The switch can be an expression that yields a value, forcing every path to produce a result. In a banking system at National Bank of India, Pankaj replaced long instanceof chains with one exhaustive pattern-matching switch per processor, and every new event type added is caught at compile time across the entire codebase.

Visual Overview

DIAGRAM
┌─────────────────────────────────────────────┐
│  Pattern Matching + Sealed Hierarchies      │
├─────────────────────────────────────────────┤
│                                             │
│  sealed interface PaymentEvent              │
│      permits Transfer, Deposit, Withdrawal  │
│                                             │
│  switch (event):                            │
│  ┌──────────────────────────────────┐       │
│  │ case Transfer(from, to, amt)     │       │
│  │   when amt > 100000             │       │
│  │   → flag high-value             │       │
│  │                                  │       │
│  │ case Transfer(from, to, amt)     │       │
│  │   → normal transfer             │       │
│  │                                  │       │
│  │ case Deposit(acct, amt)          │       │
│  │   → process deposit             │       │
│  │                                  │       │
│  │ case Withdrawal(acct, amt)       │       │
│  │   → process withdrawal          │       │
│  └──────────────────────────────────┘       │
│  No default → compiler-checked ✓            │
│                                             │
│  Add new: record Refund(...)?               │
│  → COMPILE ERROR in every switch that       │
│    does not handle Refund ✓                 │
│                                             │
│  Nested deconstruction:                     │
│  case Transfer(AccountInfo(id, bal),        │
│                AccountInfo(id2, bal2), amt)  │
│  → pulls fields from inner records          │
└─────────────────────────────────────────────┘

Key Takeaway

Record patterns and pattern matching for switch, both standardized in Java 21, let you classify and deconstruct data in one readable step. A type pattern tests the runtime type and binds a variable. A record pattern goes further and destructures a record's components into named bindings right in the case, and these patterns nest so you can pull fields out of records inside records.

Guards with the when keyword refine a case to branch on values, not just on shape. The switch can be an expression that yields a value, and null is handled explicitly with case null instead of silently throwing.

The real power appears with sealed hierarchies. If the selector is a sealed type with a known set of permitted subtypes, the compiler verifies the switch is exhaustive and needs no default. The crucial payoff is that adding a new permitted subtype fails every exhaustive switch that does not handle it, turning runtime surprises into compile errors. At National Bank of India, Pankaj replaced fragile instanceof chains with exhaustive pattern-matching switches, and every new event type is caught at compile time.

The senior point is to avoid default over a sealed type because it would silently swallow newly added subtypes. Let the compiler enforce case ordering so a more general pattern cannot shadow a more specific one, and use guarded patterns to layer business logic without breaking exhaustiveness.

Remember one sentence: record patterns deconstruct records in a case, pattern-matching switch binds by type with guards, and over a sealed hierarchy the compiler enforces exhaustiveness so adding a new permitted subtype becomes a compile error everywhere.

---

Share
Use ← → arrow keys to navigate