Definition
- 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 nullinstead of silentNullPointerException.
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.
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"))));
}
}
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.
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"))));
}
}
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.
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"))));
}
}
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.
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.
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")
)));
}
}
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.
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"))));
}
}
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.
// 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
┌─────────────────────────────────────────────┐ │ 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.
---