Definition
- 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
whencondition for business logic. - Implicit final – records satisfy the
final/sealed/non-sealedrule 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.
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.
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"))));
}
}
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.
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"))));
}
}
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.
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 {}
}
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")
)));
}
}
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.
// 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.
// 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
┌─────────────────────────────────────────────┐ │ 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.
---