Enums, Records & Sealed Types

Sealed Interfaces with Records

15 min Lesson 8 of 13

Sealed Interfaces with Records

In functional programming there is a concept called an algebraic data type (ADT) — a type that is exactly one of a fixed, known set of variants. Think of it as a closed family of shapes: a Shape is either a Circle, a Rectangle, or a Triangle — never something else, and never anonymous. Java 17 gives you first-class support for this pattern by combining sealed interfaces and records.

Why the Combination Matters

A sealed interface says: "only these types may implement me." A record says: "I am a transparent, immutable data carrier." Together they let you model a domain variant as a named, self-describing value with zero boilerplate. The compiler knows every possible case — which is what makes exhaustive switch expressions (covered in the next lesson) compile-safe.

The key insight: sealed + record means "a closed sum type where each variant is just data." This is the Java equivalent of Kotlin sealed class, Haskell data, or Rust enum with fields.

Modeling a Payment Result

Imagine a payment system where processing a charge can end in one of three outcomes: success, a soft decline (retryable), or a hard failure. Here is the ADT:

public sealed interface PaymentResult permits Success, Declined, Failed {} public record Success(String transactionId, long amountCents) implements PaymentResult {} public record Declined(String reason, boolean retryable) implements PaymentResult {} public record Failed(String errorCode, Throwable cause) implements PaymentResult {}

Notice what we got for free from the record declarations: constructors, equals, hashCode, toString, and accessors — all based on the declared components. The sealed interface contributes nothing but the constraint: only these three types exist.

The Permits Clause vs. Same-File Inference

If all permitted types are declared in the same compilation unit (same .java file or same package, depending on nesting), you can omit the permits clause and the compiler infers it. But being explicit is usually better for readability in production code:

// Explicit permits — preferred when types live in separate files public sealed interface PaymentResult permits Success, Declined, Failed {} // Implicit (all three records in the same file) — fine for small ADTs public sealed interface Shape {} record Circle(double radius) implements Shape {} record Rect(double w, double h) implements Shape {}
Use explicit permits for any ADT that will grow or that lives across multiple files. Implicit inference is convenient for small, co-located families — like in a test or a small utility package.

Records May Implement Multiple Interfaces

A record can implement more than one interface, including a mix of sealed and regular interfaces. This is useful when a variant also needs to participate in another abstraction:

public interface Auditable { String auditLabel(); } public sealed interface PaymentResult permits Success, Declined, Failed {} public record Success(String transactionId, long amountCents) implements PaymentResult, Auditable { @Override public String auditLabel() { return "SUCCESS:" + transactionId; } }

Adding Compact Constructors for Validation

Records inside a sealed hierarchy still support compact constructors for validation. If a Declined result must always carry a non-blank reason, enforce it at construction time:

public record Declined(String reason, boolean retryable) implements PaymentResult { public Declined { if (reason == null || reason.isBlank()) { throw new IllegalArgumentException("Decline reason must not be blank"); } } }

The compact constructor runs before the fields are assigned — exactly the right place for guard clauses.

Nesting: Sealed Interfaces Inside Records

ADTs can go deeper. Suppose Failed needs to distinguish between a network error and a fraud block. You can nest another sealed interface inside:

public record Failed(FailureKind kind, String detail) implements PaymentResult { public sealed interface FailureKind permits NetworkError, FraudBlock {} public record NetworkError(int httpStatus) implements FailureKind {} public record FraudBlock(String ruleId) implements FailureKind {} }

Now Failed is itself a transparent record, but its kind field is a typed, exhaustively-matchable inner ADT.

Depth vs. clarity: nesting more than two levels tends to hurt readability. If your nested structure is getting complex, consider elevating the inner sealed interface to a top-level type or breaking the hierarchy into smaller, named ADTs.

Consuming the ADT (Preview of Pattern Matching)

Even before exhaustive pattern-matching switch (Lesson 9), you can already use instanceof patterns to consume a sealed-record hierarchy cleanly:

public static String summarise(PaymentResult result) { if (result instanceof Success s) { return "Charged " + s.amountCents() + " cents, txn=" + s.transactionId(); } else if (result instanceof Declined d) { return "Declined: " + d.reason() + (d.retryable() ? " (retryable)" : ""); } else if (result instanceof Failed f) { return "Error " + f.errorCode(); } throw new AssertionError("unreachable — sealed type"); }

The throw new AssertionError line is a defensive guard. In Lesson 9 you will replace this entire method with a single switch expression, and the compiler will verify that every variant is handled — no guard needed.

Summary

Combining sealed interfaces with records gives Java a concise, type-safe way to model closed variant types (ADTs). The sealed interface names and locks the set of variants; each record provides the variant's data transparently. The compiler tracks every possible case, enabling exhaustive analysis and eliminating the need for defensive fallback branches. This pattern is the foundation for the pattern-matching switch you will write in the next lesson.