Interview Practice

Chapter 5 of 5

Records, sealed types and pattern matching

Model closed sets of data so the compiler checks every case for you.
Read aloud is unavailable in this browser. Reading preferences still work.

Records are transparent data carriers

A record declares its components once and receives a canonical constructor, accessors, equals, hashCode and toString derived from those components. Fields are final, but a record is only shallowly immutable: a List component can still be mutated by whoever holds it, so copy defensively in a compact constructor with List.copyOf. Records cannot extend other classes, may implement interfaces and fit DTOs, keys and value objects whose identity is their data.

Sealed hierarchies close the set of cases

A sealed interface lists its permitted subtypes, and each subtype must be final, sealed or non-sealed. That turns an open extension point into a known, finite set, which models results, commands and protocol messages well. Unlike an enum, each case can carry different data. The trade-off is deliberate: adding a new case is a compile-time breaking change for every exhaustive switch, which is exactly the signal you want for domain states and the wrong choice for plugin-style extension.

Pattern matching for switch in Java 21

Java 21 finalizes pattern matching for switch and record patterns. A switch over a sealed type can deconstruct each record and is checked for exhaustiveness without a default branch; the compiler reports a missing case. Guards with when refine a case. Prefer this to instanceof chains and visitor boilerplate for closed hierarchies, but keep behavior on the type itself when each case naturally owns its logic. A null selector still throws unless a case null is written.

Worked Java example

Illustrative snippet: imports, surrounding types or collaborators may be omitted. Read the assumptions before adapting it.

sealed interface Payment permits Card, Wallet, BankTransfer {}
record Card(String last4, long cents) implements Payment {}
record Wallet(String provider, long cents) implements Payment {}
record BankTransfer(String iban, long cents, boolean instant) implements Payment {}

static long feeCents(Payment p) {
    return switch (p) {
        case Card c -> Math.max(30, c.cents() * 29 / 1000);
        case Wallet w when w.provider().equals("internal") -> 0;
        case Wallet w -> w.cents() / 100;
        case BankTransfer(var iban, var cents, var instant) -> instant ? 50 : 0;
    };
}

Walkthrough

The switch has no default, so adding a Crypto record to the permits list makes feeCents fail to compile until it is handled. The guarded Wallet case must precede the unguarded one, or the compiler reports it as dominated. The BankTransfer case uses a record pattern to bind components directly. A Card of 10,000 cents pays max(30, 290) = 290; a 500-cent card pays the 30-cent minimum.

Practice it yourself

A teammate adds default -> 0 to the switch "for safety". Explain what that costs and when a default is still appropriate.

Check your reasoning

The default silently assigns a zero fee to any future Payment subtype, turning a compile error into a revenue bug discovered in production. Without it the compiler forces a decision for every new case. A default remains reasonable for open types such as Object or non-sealed interfaces, where exhaustiveness cannot be proven, and should usually throw rather than return a plausible value.

Recall checklist

  • Explain the main invariant or contract without looking.
  • Trace the example and test an edge case.
  • State time, space, input variables and assumptions where relevant.
  • Explain one alternative and when you would choose it.

Read further