Back to Sealed Classes & Interfaces
Q9Question 9 of 12

How are sealed classes and records complementary in Java's pattern-matching vision?

Key Concepts
  • Sealed types say "it is exactly one of these" (sum type); records say "and here are its fields" (product type).
  • Pattern matching in `switch` reads both in a single exhaustive step.
  • Record patterns deconstruct a record's components directly in a case: `case Transfer(var from, var to, var amount)`.
  • Records are implicitly `final`, so they satisfy the sealed-subtype rule automatically.
  • Guarded patterns (`when` clause) layer business conditions on top of structural matching.
  • Adding a new permitted record type fails every exhaustive switch at compile time.
  • Nested record patterns destructure records inside records in a single expression.

Definition

Sealed types say "it is exactly one of these," records say "and here are its fields," and pattern matching reads both in a single exhaustive switch.
  • Sealed type = sum – defines a closed set of permitted subtypes; a value is exactly one of the alternatives.
  • Record = product – defines an immutable, transparent bundle of named components; a value bundles fields.
  • Record pattern – destructures a record's components directly in a case: case Transfer(var from, var to, var amount).
  • Exhaustive switch – over a sealed type, the compiler verifies every permitted case is handled without a default.
  • Guarded pattern – refines a case with a when condition for business logic.
  • Implicit final – records satisfy the final/sealed/non-sealed rule automatically.

Imagine National Bank of India has a fixed menu of exactly three transaction types: Deposit, Withdrawal, and Transfer. That closed menu is the sealed type. Each transaction type is a pre-printed form with fixed fields: a Transfer form always has "from account", "to account", and "amount." That fixed form is the record. When a clerk processes a transaction, they first look at which form it is (sealed choice) and then immediately read the fields printed on it (record deconstruction).

Understanding the Concepts

How are sealed types and records two halves of one design?

A sealed hierarchy is a sum type: the value is exactly one of a closed set of alternatives. A record is a product type: the value bundles named components. Together they form algebraic data types, and Java's pattern matching consumes both in one step.

JAVA
package com.bank.model;

import java.math.BigDecimal;

public sealed interface Txn
        permits Txn.Deposit, Txn.Withdrawal, Txn.Transfer {

    record Deposit(String account, BigDecimal amount) implements Txn {}
    record Withdrawal(String account, BigDecimal amount) implements Txn {}
    record Transfer(String from, String to, BigDecimal amount) implements Txn {}
}

How do record patterns deconstruct in a switch?

A record pattern binds the record's components directly in the case, so you never call accessor methods.

JAVA
package com.bank.service;

import com.bank.model.Txn;
import com.bank.model.Txn.*;
import java.math.BigDecimal;

public class TxnProcessor {

    public String describe(Txn txn) {
        return switch (txn) {
            case Deposit(var account, var amount) ->
                    "Deposit " + amount + " INR to " + account;
            case Withdrawal(var account, var amount) ->
                    "Withdraw " + amount + " INR from " + account;
            case Transfer(var from, var to, var amount) ->
                    "Transfer " + amount + " INR: " + from + " -> " + to;
        };
    }

    public static void main(String[] args) {
        TxnProcessor processor = new TxnProcessor();
        System.out.println(processor.describe(
                new Deposit("SAV-1001", new BigDecimal("50000"))));
        System.out.println(processor.describe(
                new Transfer("SAV-1001", "SAV-2002", new BigDecimal("25000"))));
    }
}
TEXT
Deposit 50000 INR to SAV-1001
Transfer 25000 INR: SAV-1001 -> SAV-2002

How do guarded patterns add business logic?

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

JAVA
package com.bank.service;

import com.bank.model.Txn;
import com.bank.model.Txn.*;
import java.math.BigDecimal;

public class ComplianceProcessor {
    private static final BigDecimal LIMIT = new BigDecimal("100000");

    public String classify(Txn txn) {
        return switch (txn) {
            case Transfer(var f, var t, var amount)
                    when amount.compareTo(LIMIT) > 0 ->
                    "HIGH VALUE transfer: " + amount + " INR (needs approval)";
            case Transfer(var f, var t, var amount) ->
                    "Normal transfer: " + amount + " INR";
            case Deposit(var account, var amount) ->
                    "Deposit: " + amount + " INR";
            case Withdrawal(var account, var amount) ->
                    "Withdrawal: " + amount + " INR";
        };
    }

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

How do nested record patterns work?

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

JAVA
package com.bank.model;

import java.math.BigDecimal;

public sealed interface Txn2 {
    record AccountInfo(String id, BigDecimal balance) {}
    record Transfer(AccountInfo from, AccountInfo to, BigDecimal amount)
            implements Txn2 {}
    record Deposit(AccountInfo account, BigDecimal amount)
            implements Txn2 {}
}
JAVA
package com.bank.service;

import com.bank.model.Txn2;
import com.bank.model.Txn2.*;
import java.math.BigDecimal;

public class NestedPatternDemo {
    public String process(Txn2 txn) {
        return switch (txn) {
            // Nested deconstruction: pull fields from inner record
            case Transfer(AccountInfo(var fromId, var fromBal),
                          AccountInfo(var toId, var toBal), var amount) ->
                    fromId + " (bal: " + fromBal + ") -> "
                            + toId + " (bal: " + toBal + "): " + amount + " INR";
            case Deposit(AccountInfo(var id, var bal), var amount) ->
                    "Deposit " + amount + " to " + id + " (bal: " + 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 (bal: 500000) -> SAV-2002 (bal: 200000): 75000 INR

Why are records implicitly final?

Records are implicitly final, so they satisfy the sealed-subtype rule automatically. When a sealed interface permits a record, you do not need to write final on the record.

JAVA
// Records are implicitly final: this just works
sealed interface PaymentResult {
    record Success(BigDecimal amount) implements PaymentResult {}
    record Failure(String reason) implements PaymentResult {}
}
// No 'final' keyword needed on Success or Failure

What happens when you add a new record type?

Every exhaustive switch that does not handle the new type fails to compile. This turns "forgot a case" from a runtime bug into a compile error.

JAVA
// Add a new permitted type:
// record Pending(String expectedDate) implements PaymentResult {}

// Every existing switch over PaymentResult now fails to compile:
// error: the switch expression does not cover all possible input values

How is this different from the old instanceof chain?

| Feature | instanceof chain | Sealed + Record pattern switch |
|---|---|---|
| Exhaustiveness | Not checked by compiler | Compiler-verified |
| Deconstruction | Manual cast + getters | Direct component binding |
| Adding a variant | Silently missed | Compile error |
| Null handling | Manual NPE risk | Explicit case null |

What should a senior engineer know?

Prefer exhaustive switches without a default over a sealed type, because a default would silently swallow a newly added subtype and defeat the exhaustiveness check. Records as data shapes plus sealed interfaces as sum types is the idiomatic Java 21+ pattern for domain modelling. Guards with when layer business rules without breaking exhaustiveness.

Visual Overview

DIAGRAM
┌─────────────────────────────────────────────┐
│  Sealed + Records + Pattern Matching        │
├─────────────────────────────────────────────┤
│                                             │
│  sealed interface Txn (SUM TYPE)            │
│       │                                     │
│       ├── record Deposit(acct, amt)  PRODUCT│
│       ├── record Withdrawal(acct, amt) PROD │
│       └── record Transfer(from, to, amt) PR │
│                                             │
│  switch (txn):                              │
│  ┌──────────────────────────────────┐       │
│  │ case Deposit(var a, var amt)     │       │
│  │   → use a, amt directly         │       │
│  │                                  │       │
│  │ case Withdrawal(var a, var amt)  │       │
│  │   → use a, amt directly         │       │
│  │                                  │       │
│  │ case Transfer(var f, var t, amt) │       │
│  │   when amt > LIMIT              │       │
│  │   → flag as high value          │       │
│  │                                  │       │
│  │ case Transfer(var f, var t, amt) │       │
│  │   → normal transfer             │       │
│  └──────────────────────────────────┘       │
│  Exhaustive: no default needed ✓            │
└─────────────────────────────────────────────┘

Key Takeaway

Sealed classes and records are the two halves of algebraic data types in Java. A sealed type is a sum type that says a value is exactly one of a closed set of alternatives. A record is a product type that bundles named components into an immutable value. Pattern matching consumes both in a single exhaustive switch.

In a banking system at National Bank of India, Pankaj models transactions as a sealed Txn interface permitting three records: Deposit, Withdrawal, and Transfer. The processor uses record patterns to bind components directly in each case, and a guarded case flags high-value transfers for compliance review. No default branch is needed because the compiler knows the set is complete.

The real payoff is safety. If the bank introduces a fourth transaction kind, every exhaustive switch across the codebase fails to compile until it handles the new kind. That turns "we forgot to handle a case" from a production incident into a compile error. At the same time, record patterns keep the processing code clean because you read the structure declaratively without chains of getter calls.

The senior point is to avoid default over a sealed type because a default would silently swallow a newly added subtype. Records are implicitly final, so they satisfy the sealed-subtype rule automatically. Guarded patterns with when layer business conditions without breaking exhaustiveness, and nested record patterns destructure inner records in one expression.

Remember one sentence: sealed types describe which alternatives exist, records describe the fields inside each alternative, and pattern matching consumes both at once with compiler-checked exhaustiveness.

---

Share
Use ← → arrow keys to navigate