Algebraic Data Types — Records, Sealed Classes, and Pattern Matching
Prerequisites: interfaces-default-methods-traits
What You'll Learn
- Write a
recorddeclaration and understand what the compiler auto-generates: the canonical constructor, accessor methods,equals(),hashCode(), andtoString(). - Design a sealed class hierarchy using records to model a Rust-style algebraic data type.
- Write an exhaustive
switchexpression over a sealed type hierarchy and understand why the compiler can verify exhaustiveness. - Use record patterns in
instanceofexpressions andswitchcases to destructure nested records in a single step. - Identify the key differences between a Java
recordand a Rust struct — specifically around heap allocation, move semantics, and null safety.
Why This Matters
If you've spent time with Rust enums and match expressions, this module will feel like coming home — and then noticing the furniture is arranged differently. Java's records, sealed classes, and pattern matching together form Java's answer to algebraic data types (ADTs). They let you model data that is exactly one of several known shapes, pattern-match over it exhaustively, and destructure it in place. The compiler enforces exhaustiveness just like Rust does.
What they do not give you: Rust's ownership system, stack allocation by default, or the guarantee that every field is non-null. The resemblance is real, but the underlying guarantees differ in ways that matter at scale. This module shows you both the power and the limits.
Core Concept
If you've modeled domain state in Rust, you've used product types (structs: "this AND this AND this") and sum types (enums: "this OR this OR this"). Java now has both: record for product types, and the combination of sealed types with record implementations for sum types.
Records: Product Types
A record declaration in Java is a concise way to define an immutable data carrier. The compiler auto-generates everything you'd otherwise write by hand:
// Java
record Payment(long id, String fromAccount, String toAccount, double amount, String currency) {}// Rust equivalent
#[derive(Debug, PartialEq, Clone)]
struct Payment {
id: u64,
from_account: String,
to_account: String,
amount: f64,
currency: String,
}
For the Java record above, the compiler generates:
- A canonical constructor
Payment(long id, String fromAccount, String toAccount, double amount, String currency). - Accessor methods — not getters. The method name matches the field name:
payment.id(),payment.fromAccount(),payment.amount(). Neverpayment.getId(). equals()andhashCode()based on all fields.toString()in the formPayment[id=42, fromAccount=ACC-001, ...].
Fields are final. There are no setters. Records are immutable at the API level.
You can add a compact constructor to add validation:
record Payment(long id, String fromAccount, String toAccount, double amount, String currency) {
Payment {
if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
if (fromAccount == null || toAccount == null) throw new NullPointerException("Accounts must not be null");
}
}
The compact constructor body runs inside the canonical constructor. You do not repeat the parameter list or the field assignments — the compiler inserts the assignments after your validation block.
Sealed Classes: Sum Types
A sealed type hierarchy restricts which types may extend or implement a given class or interface. You saw sealed interfaces in Module 04. Sealed classes work identically.
Note: Both sealed classes and sealed interfaces are sealed type hierarchies. They use the same
sealedandpermitskeywords. The only difference is whether you declaresealed classorsealed interface. The constraints and the syntax are identical.
// Java
sealed interface PaymentResult permits PaymentSuccess, PaymentFailure {}
record PaymentSuccess(String transactionId, double amount) implements PaymentResult {}
record PaymentFailure(String reason, ErrorCode code) implements PaymentResult {}
enum ErrorCode { INSUFFICIENT_FUNDS, INVALID_ACCOUNT, NETWORK_TIMEOUT }// Rust equivalent
enum PaymentResult {
Success { transaction_id: String, amount: f64 },
Failure { reason: String, code: ErrorCode },
}
enum ErrorCode { InsufficientFunds, InvalidAccount, NetworkTimeout }
Every type listed in the permits clause must either be final, sealed (with its own permits), or declared non-sealed (which opens it back up to arbitrary extension). For records, final is implied — records cannot be subclassed.
All permitted types must be in the same package as the sealed type, or in the same compilation unit.
Exhaustive Switch Expressions
Once you have a sealed type hierarchy, the compiler can verify that your switch expression covers all cases. If you add a new permitted type and forget to update a switch, the code will not compile:
// Java
String message = switch (result) {
case PaymentSuccess(var txId, var amt) -> "Paid " + amt + " (txn: " + txId + ")";
case PaymentFailure(var reason, var code) -> "Failed: " + reason + " [" + code + "]";
};
The var keyword inside the pattern binds the record's fields to local variables. The deconstruction and field extraction happen in the case label itself — no separate field access needed.
Rust comparison: This is Java's
match. The compiler knows the full set of permitted types because of thesealeddeclaration. If you handle all of them, nodefaultcase is required or expected. If you miss one, the compiler tells you.
Record Patterns in instanceof
You can also use record patterns in instanceof expressions outside of a switch:
// Java
if (result instanceof PaymentSuccess(var txId, var amt)) {
System.out.printf("Transaction %s: %.2f%n", txId, amt);
}
And nested deconstruction works too:
record Point(int x, int y) {}
record Circle(Point center, int radius) {}
if (shape instanceof Circle(Point(var x, var y), var r)) {
System.out.printf("Circle centered at (%d, %d) with radius %d%n", x, y, r);
}Guarded Patterns
You can add a condition after a pattern case using the when keyword:
switch (result) {
case PaymentSuccess(var txId, var amt) when amt > 10_000 ->
logHighValueTransaction(txId, amt);
case PaymentSuccess(var txId, var amt) ->
logStandardTransaction(txId, amt);
case PaymentFailure(var reason, var code) ->
logFailure(reason, code);
}
The compiler checks the cases in order. A guarded pattern only matches when both the type test and the when condition are true. Unguarded cases for the same type are still required for exhaustiveness — a guarded case does not cover the full type.
Concrete Example
Here is the complete PaymentResult hierarchy in the payment-processor project, along with a method that processes a payment and an exhaustive switch that handles both outcomes.
All code lives in src/main/java/com/example/payments/.
// PaymentResult.java
package com.example.payments;
public sealed interface PaymentResult permits PaymentSuccess, PaymentFailure {}// PaymentSuccess.java
package com.example.payments;
public record PaymentSuccess(String transactionId, double amount) implements PaymentResult {}// PaymentFailure.java
package com.example.payments;
public record PaymentFailure(String reason, ErrorCode code) implements PaymentResult {}// ErrorCode.java
package com.example.payments;
public enum ErrorCode {
INSUFFICIENT_FUNDS,
INVALID_ACCOUNT,
NETWORK_TIMEOUT
}// PaymentProcessor.java
package com.example.payments;
public class PaymentProcessor {
public PaymentResult processPayment(Payment payment) {
if (payment.amount() <= 0) {
return new PaymentFailure("Amount must be positive", ErrorCode.INVALID_ACCOUNT);
}
if (payment.fromAccount().equals(payment.toAccount())) {
return new PaymentFailure("Cannot transfer to same account", ErrorCode.INVALID_ACCOUNT);
}
// Simulate a successful transaction
String txId = "TXN-" + payment.id();
return new PaymentSuccess(txId, payment.amount());
}
public void handleResult(PaymentResult result) {
String logEntry = switch (result) {
case PaymentSuccess(var txId, var amt) when amt > 10_000 -> {
logHighValueTransaction(txId, amt);
yield "HIGH_VALUE: " + txId;
}
case PaymentSuccess(var txId, var amt) ->
"SUCCESS: " + txId + " for " + amt;
case PaymentFailure(var reason, var code) ->
"FAILURE [" + code + "]: " + reason;
};
System.out.println(logEntry);
}
private void logHighValueTransaction(String txId, double amount) {
System.out.printf("ALERT: High-value transaction %s for %.2f%n", txId, amount);
}
}
Note the yield keyword inside the block-form case arm. When a case arm needs more than a single expression (multiple statements), you open a block with { and use yield to produce the value.
Usage:
var processor = new PaymentProcessor();
var payment = new Payment(1L, "ACC-001", "ACC-002", 15_000.0, "USD");
PaymentResult result = processor.processPayment(payment);
processor.handleResult(result);
// Prints:
// ALERT: High-value transaction TXN-1 for 15000.00
// HIGH_VALUE: TXN-1Analogy
Think of a sealed type hierarchy as an official form with pre-printed checkbox options. The form (the sealed interface) declares exactly which boxes exist. When you receive a completed form (a value), you check which box is ticked (pattern matching). The form issuer guarantees no new boxes will appear — so you can design your response process knowing you have covered all possibilities. If the form is revised to add a new option (a new permitted type), every processing step that inspects the form will need updating before it can compile.
A record is like a notarized data sheet: once filled out, the fields cannot change, and the system can always compare two sheets field-by-field to determine if they represent the same payment.
Going Deeper
When to Use enum vs. a Sealed Class Hierarchy
Java enum and sealed type hierarchies both create closed sets, but they serve different purposes:
enum | Sealed class hierarchy | |
|---|---|---|
| Best for | A flat set of named constants | Variants with different shapes (different fields per variant) |
| Associated data | Limited (shared fields only) | Full: each permitted type has its own fields |
| Pattern matching | Yes, on the constant value | Yes, with deconstruction |
| Multiple levels | No | Yes, permitted types can themselves be sealed |
Use enum for ErrorCode, Currency, Status. Use a sealed interface for PaymentResult where each variant carries distinct data.
Generic Records and Erasure
Records can be generic:
record Pair<A, B>(A first, B second) {}
But type parameters are erased at runtime (as covered in Module 03). You cannot pattern-match on the type arguments:
// This does NOT compile — type parameters are erased
if (pair instanceof Pair<String, Integer>(var s, var i)) { ... }
// This compiles — you match on the raw Pair type only
if (pair instanceof Pair(var first, var second)) { ... }
This is a fundamental difference from Rust, where monomorphization preserves type information and you can match on specific type instantiations.
Non-Sealed Permits
A permitted subtype can be declared non-sealed, which reopens it for arbitrary extension:
sealed interface Shape permits Circle, Rectangle, UnknownShape {}
non-sealed class UnknownShape implements Shape {}
class TriangleHack extends UnknownShape {} // allowed
This defeats exhaustiveness checking for UnknownShape — a switch over Shape must include a case UnknownShape catch-all. Use non-sealed sparingly; it is an escape hatch for interoperability with legacy code.
Custom Compact Constructors and Null Defense
Records do not prevent null component values unless you explicitly reject them:
record PaymentSuccess(String transactionId, double amount) implements PaymentResult {
PaymentSuccess {
Objects.requireNonNull(transactionId, "transactionId must not be null");
if (amount < 0) throw new IllegalArgumentException("amount must be non-negative");
}
}
Without this, new PaymentSuccess(null, 100.0) will construct successfully and blow up later when you call .transactionId(). Rust's type system makes absence explicit via Option<T>; Java records have no such enforcement by default.
Common Misconceptions
1. "Records are immutable, so they behave like Rust value types."
Records are immutable at the API level: fields are final, there are no setters. But they are always heap-allocated objects in Java. There is no stack allocation, no move semantics, no ownership transfer. When you assign a record to another variable, you copy a reference, not the value. Two variables can point to the same record object. The immutability is an API contract — it prevents mutation — but it does not change the memory model.
2. "Sealed classes replace enums."
They solve adjacent problems. Sealed classes let each variant carry its own distinct fields; Java enums do not (enum fields must be shared across all constants). But enums are still the right tool for flat sets of named values: ErrorCode.INSUFFICIENT_FUNDS is cleaner than a sealed class hierarchy with empty records. Use enums for simple closed sets; use sealed class hierarchies for richer product-within-sum structures.
3. "Adding a default case to a sealed switch is fine for safety."
Adding a default arm to a switch over a sealed type hierarchy defeats the exhaustiveness guarantee. If you add a new permitted type later, the compiler will not warn you — the default arm silently catches the new case. Prefer to list all cases explicitly; let the compiler catch missing coverage. Use default only when you intentionally want to suppress coverage (for example, when pattern-matching on a non-sealed branch).
Check Your Understanding
-
You write
record PaymentSuccess(String transactionId, double amount) implements PaymentResult {}. A colleague callsnew PaymentSuccess(null, 500.0). Does the compiler reject this? Why or why not?Answer: No, the compiler does not reject it. Records do not enforce non-null fields by default.
nullis a validStringvalue in Java. The constructor will succeed, and you will get aNullPointerExceptiononly when code later calls.transactionId()and dereferences the null. To prevent this, add a compact constructor that callsObjects.requireNonNull(transactionId, ...). -
Why does the compiler require no
defaultcase in an exhaustiveswitchover a sealed type hierarchy, even though adefaultwould normally be required for aswitchover an interface type?Answer: A
switchover a regular interface type could match any number of unknown implementing classes at runtime, so adefaultis required to handle unknown cases. A sealed interface has a closedpermitsclause: the compiler knows, at compile time, every type that can implement it. If the switch cases cover all permitted types, the compiler can verify exhaustiveness statically and requires nodefault. This is the core guarantee of sealed type hierarchies. -
You have
record Pair<A, B>(A first, B second) {}. You write a switch that handlesPair<String, Integer>separately fromPair<Integer, String>. What happens, and why?Answer: The switch cannot distinguish between the two. Java erases generic type parameters at runtime (type erasure, covered in Module 03). At runtime, both are just
Pairobjects — theAandBtype information is gone. The pattern match will match onPairwithout regard to the type arguments. If you need to distinguish them, you must add a runtime type check on the fields themselves:if (pair.first() instanceof String s && pair.second() instanceof Integer i). -
A
PaymentSuccessis arecord. Can you subclass it to add aloyaltyPointsfield?Answer: No. Records are implicitly
final— they cannot be subclassed. This is by design: records are transparent data carriers, and allowing subclassing would undermine the guarantees thatequals()andhashCode()can be defined purely in terms of the declared components. If you need a richer type, create a new record or use a sealed class that is not a record. -
You add a third permitted type
PaymentPendingtoPaymentResult. Where does your code break?Answer: Every exhaustive
switchexpression overPaymentResultthat does not have adefaultcase will fail to compile. The compiler re-checks coverage whenever a sealed type'spermitsclause changes. This is the key guarantee: the compiler forces you to handle new variants at every switch site, preventing silent missed cases. It is the Java equivalent of the Rust compiler telling you that yourmatchis non-exhaustive.
Key Takeaways
- A
recordis Java's product type: an immutable data carrier whose constructor, accessors,equals(),hashCode(), andtoString()are compiler-generated. Accessor names match field names (payment.id(), notpayment.getId()). - A sealed type hierarchy is Java's sum type: a sealed interface or class with a
permitsclause restricts which types may implement or extend it. Sealed classes and sealed interfaces use identicalsealed/permitssyntax — the target (class vs. interface) is the only difference. - Exhaustive
switchexpressions over sealed types are compiler-verified. If you add a permitted type, every uncovered switch site fails to compile. Never add adefaultto a sealed switch — it defeats this guarantee. - Java records are always heap-allocated. There are no move semantics, no stack allocation, and no borrow checker. Immutability is API-level only;
nullis still possible without an explicit compact constructor guard. - Use
enumfor flat closed sets of named constants. Use a sealed class hierarchy when each variant needs different fields. The two tools solve adjacent problems and are not interchangeable.
References
- JEP 440: Record Patterns — The finalized JEP specifying record pattern syntax, nested deconstruction, and the semantics of record pattern matching in
instanceofandswitch. - JEP 441: Pattern Matching for switch — The finalized JEP for switch pattern matching, covering exhaustiveness rules, guarded patterns (
when), and the interaction with sealed type hierarchies. - Pattern Matching for switch Expressions and Statements (Oracle Java 21 Docs) — Oracle's official Java 21 reference documentation with syntax rules, exhaustiveness requirements, and guarded pattern semantics.
- Java's Modern Toolbox: Records, Sealed Classes, and Pattern Matching — Practical guide showing how records, sealed classes, and pattern matching compose to form idiomatic Java ADTs.
- Data Classes and Sealed Types for Java (OpenJDK Design Notes) — The design rationale from the OpenJDK Amber project, explaining why records and sealed classes were designed the way they were and what use cases they target.