Modern Java for Rust Engineers
Module 5 of 8 Intermediate 40 min

Algebraic Data Types — Records, Sealed Classes, and Pattern Matching

Prerequisites: interfaces-default-methods-traits

What You'll Learn

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:

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 sealed and permits keywords. The only difference is whether you declare sealed class or sealed 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 the sealed declaration. If you handle all of them, no default case 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-1

Analogy

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:

enumSealed class hierarchy
Best forA flat set of named constantsVariants with different shapes (different fields per variant)
Associated dataLimited (shared fields only)Full: each permitted type has its own fields
Pattern matchingYes, on the constant valueYes, with deconstruction
Multiple levelsNoYes, 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

  1. You write record PaymentSuccess(String transactionId, double amount) implements PaymentResult {}. A colleague calls new 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. null is a valid String value in Java. The constructor will succeed, and you will get a NullPointerException only when code later calls .transactionId() and dereferences the null. To prevent this, add a compact constructor that calls Objects.requireNonNull(transactionId, ...).

  2. Why does the compiler require no default case in an exhaustive switch over a sealed type hierarchy, even though a default would normally be required for a switch over an interface type?

    Answer: A switch over a regular interface type could match any number of unknown implementing classes at runtime, so a default is required to handle unknown cases. A sealed interface has a closed permits clause: 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 no default. This is the core guarantee of sealed type hierarchies.

  3. You have record Pair<A, B>(A first, B second) {}. You write a switch that handles Pair<String, Integer> separately from Pair<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 Pair objects — the A and B type information is gone. The pattern match will match on Pair without 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).

  4. A PaymentSuccess is a record. Can you subclass it to add a loyaltyPoints field?

    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 that equals() and hashCode() 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.

  5. You add a third permitted type PaymentPending to PaymentResult. Where does your code break?

    Answer: Every exhaustive switch expression over PaymentResult that does not have a default case will fail to compile. The compiler re-checks coverage whenever a sealed type's permits clause 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 your match is non-exhaustive.

Key Takeaways

References