Back to Blog

Records, sealed interfaces, and exhaustive switch: one modeling primitive

Records, sealed interfaces, and pattern matching are usually taught as three Java features. Together they form one modeling primitive that turns a fail-open security bug into a compile error.

Lukas GrigisSoftware Architect9 min read
0:00 / 0:00
javarecordssealed-classespattern-matchingdata-modelingdomain-modeling
A segmented ring gate in deep space irising open, warm amber light pouring through the aperture: the single way through a sealed Java switch.
On this page

Key takeaways

  • Records, sealed interfaces, and pattern matching combine into one modeling primitive: a closed set of alternatives the compiler checks exhaustively. Other languages call it an algebraic data type.
  • At a security gate the payoff is concrete: add a fifth validation outcome and every switch stops compiling, handing you the complete to-do list of gates to update.
  • An instanceof ladder over the same domain keeps compiling when the domain grows: the new outcome falls through the final branch and the request is allowed. That is a fail-open bug.
  • The part that does the proving is the least adopted one: 55% report using or planning records, but only 30% sealed classes. Records are the convenience; the sealed set is what buys the compile error.

Records, sealed interfaces, and pattern matching ship as three separate Java features, and most of us adopted them that way: records to kill boilerplate, sealed to lock down a hierarchy, pattern matching to shorten casts. Each one alone is a minor convenience. Together they are something else: one modeling primitive, where the compiler proves that every consumer handles every case of your domain. At a security boundary that proof matters, because there the unhandled case is not a cosmetic gap but a request that gets through. This post walks the smallest honest example I could build: the data-modeling brick of java-foundations.

The claim: one primitive, and sealed is the part that proves it

The primitive has a name in other languages, an algebraic data type, and it has three parts:

  • The sealed interface declares the closed set of alternatives: "validating a token ends in exactly one of these four outcomes."
  • Each record carries one alternative's data and nothing else. No behavior smuggled in, no mutable state.
  • The exhaustive switch is the consumer the compiler checks against that set.

The three parts are not interchangeable, and it pays to be precise about which one does the proving. Take the records out and put plain final classes in their place: the switch still refuses to compile when a case is missing. Take the sealed interface out and you cannot write a switch without a default at all. The check comes from the closed set and the checked consumer. What the records add is that each alternative arrives carrying its own data instead of being a marker class you have to cast and interrogate.

That split explains the adoption numbers, and they are worth a look. In BellSoft's 2024 developer survey (308 respondents at Devoxx Belgium), 55% said they were currently using or planning to use records in production and 53% pattern matching, but only 30% sealed classes. The two convenient parts went in first. The part that turns them into a guarantee is the one still sitting on the shelf.

The domain: four token-validation outcomes, one file

The brick models bearer-token validation, the check a resource server runs before it lets a request through. The domain is built for this post rather than lifted out of a production codebase: four outcomes is the smallest set that still behaves like a real gate, and it keeps the example inside one screen. If you want the surrounding machinery, that is the subject of the Spring Boot OAuth2 guide; here everything below the outcome itself is deliberately missing. The whole domain fits in one file, TokenValidation.java:

java|TokenValidation.java
public sealed interface TokenValidation {

    record Valid(String subject, List<String> scopes, Instant expiresAt) implements TokenValidation {

        /**
         * Defensive copy so a Valid outcome cannot grow or lose scopes after the fact.
         */
        public Valid {
            scopes = List.copyOf(scopes);
        }

    }

    record Expired(Instant expiredAt) implements TokenValidation {

    }

    record WrongAudience(String expected, String actual) implements TokenValidation {

    }

    record Malformed(String reason) implements TokenValidation {

    }

}

Read it as a sentence: validating a token ends in Valid, Expired, WrongAudience, or Malformed, and each alternative says what data it carries. Expired knows when; WrongAudience knows which two audiences disagreed; Malformed knows why. There are no annotations and no base-class ceremony. The file is the domain model.

The consumer: an exhaustive switch with no default

The interesting part is what the consumer, TokenGate.java, does not contain: no default branch, no visitor interface, no instanceof ladder.

java|TokenGate.java
public static Optional<Problem> deny(TokenValidation outcome) {
    return switch (outcome) {
        case Valid _ -> Optional.empty(); // the only way through — visibly a decision
        case Expired(Instant expiredAt) ->
                Optional.of(new Problem(401, "invalid_token: token expired at " + expiredAt));
        case WrongAudience(String expected, String actual) ->
                Optional.of(new Problem(401, "invalid_token: audience is \"" + actual
                        + "\", this resource expects \"" + expected + "\""));
        case Malformed(String reason) ->
                Optional.of(new Problem(401, "invalid_token: " + reason));
    };
}

An empty result means the request proceeds, and Valid is visibly the only case that produces it. Record patterns deconstruct each alternative in place, components a case doesn't need are unnamed (_), and because the set is sealed, the compiler knows these four cases are all there are. The denial shapes follow RFC 6750: a bad bearer token is a 401 with an invalid_token hint.

A second consumer in the same file, auditLine, maps the same outcomes to audit lines. It also adds a when guard: a still-valid token within 60 seconds of expiry gets a warning. Exhaustiveness survives that, because the unguarded Valid case below it still covers the rest.

The experiment: add a fifth outcome

Here's the part you should run yourself. Clone the repo, open TokenValidation.java, and add:

java
record Revoked(String reason) implements TokenValidation {}

Both switches in TokenGate.java immediately stop compiling:

the switch expression does not cover all possible input values

The compiler just handed you the complete to-do list of every gate that must decide what a revoked token means. That is the whole payoff: "you forgot to handle the new outcome" stops being a code-review catch and becomes a compile error, caught by the one reviewer who never skims. Revert the record and the brick compiles clean again.

The counter-example: an instanceof ladder that fails open

The repo keeps the same gate as an instanceof ladder in InstanceofValidation.java, so you can run the difference instead of taking my word for it:

java|InstanceofValidation.java
public static Optional<Problem> deny(TokenValidation outcome) {
    if (outcome instanceof Expired(Instant expiredAt)) {
        return Optional.of(new Problem(401, "invalid_token: token expired at " + expiredAt));
    }
    if (outcome instanceof WrongAudience(String expected, String actual)) {
        return Optional.of(new Problem(401, "invalid_token: audience is \"" + actual
                + "\", this resource expects \"" + expected + "\""));
    }
    if (outcome instanceof Malformed(String reason)) {
        return Optional.of(new Problem(401, "invalid_token: " + reason));
    }
    // "Everything else must be valid" — the fail-open branch that admits future outcomes.
    return Optional.empty();
}

Today, the demo verifies at runtime that this ladder and the switch produce identical denials for every outcome. But look at the shape: the ladder enumerates the failures it knows and treats everything else as valid. Valid isn't even mentioned; acceptance is whatever fails to match. Add Revoked, and the ladder still compiles without a sound. A revoked token matches none of the checks, falls through to Optional.empty(), and the request is allowed. There is no warning, no diff on this file at all, and no test failure until someone thinks to write that test. The mapping and the output are identical today, and tomorrow it is a fail-open security bug. (The visitor pattern can force exhaustiveness, but at several lines of ceremony per alternative per consumer, which is why real codebases fall back to exactly this ladder.)

Where the guarantee stops

Two limits are worth knowing before you go and seal everything in sight.

The first is that a compile-time proof only binds code that was compiled against the current set. Add an alternative, ship the library, and a consumer built against the old set keeps running: at runtime its switch throws MatchException instead of failing anyone's build. Closed rather than open, so the gate still holds, but a crash is not the same thing as a compiler handing you a list. The sharper version of this is for published APIs. Once a sealed interface is part of one, every new alternative is a breaking change for everybody who consumes it, and that is the honest price of the guarantee: you cannot force other people's code to handle a new case without also breaking their build.

The second is knowing when not to reach for it. A sealed set fits when the alternatives are yours to enumerate. When the set is meant to grow from outside, through plugins or third-party implementations, ordinary polymorphism is the right tool and sealing it only moves the problem somewhere less convenient. It also helps to know that Java is late to this idea rather than early: Rust calls it an enum, Kotlin a sealed class, Scala an ADT, TypeScript a discriminated union. What 21 changed is that the Java spelling of it finally reads as well as theirs.

Try it

The brick is a standalone Maven project, pure JDK with zero dependencies, because the point is what the language does on its own. DataModelingDemo.java runs a fixed set of outcomes through both consumers and prints the gate decisions, the audit lines, and the ladder-vs-switch sanity check:

git clone https://github.com/lukas-grigis/java-foundations
cd java-foundations
mise run data-modeling

That is what the mise pin is for: it fetches JDK 26 and Maven for you. On a toolchain you manage yourself, the same thing is cd bricks/data-modeling && mvn -q compile && java -cp target/classes dev.lukasgrigis.foundations.datamodeling.DataModelingDemo, and it wants JDK 26 specifically, because the pom sets release 26 and anything older stops with error: release version 26 not supported. The language features are older than the brick: sealed types with pattern matching for switch have been final since 21, unnamed variables since 22.

This is the data-modeling brick of java-foundations: one design primitive of modern Java, made to land with a small, runnable example rather than a framework demo or a syntax tour. Next time you reach for a record, ask whether it's actually one alternative of a closed set. If it is, seal the set and delete the default branch, especially at a boundary where the fall-through's behavior becomes the policy for every outcome you add later. The compiler will start doing review work you used to do by hand. The next brick picks another one of these foundations and gets the same treatment, with a module you can run behind it.

Frequently asked questions

Why should a sealed switch have no default branch?

Because the default branch is where the guarantee dies. Without it, the compiler checks the switch against the sealed interface's permitted set and refuses to compile if a case is missing. With a default, a newly added alternative silently falls into it and the compiler stays quiet. You have traded a compile-time check for a runtime surprise.

Do records, sealed interfaces, and pattern matching work independently?

Yes, and that is the trap. Each one alone is a minor convenience: records save boilerplate, sealed types restrict subclassing, pattern matching shortens casts. The exhaustiveness check itself comes from the sealed set plus the switch, and swapping the records for plain final classes keeps it. What the records add is that every alternative carries its own data, which is what makes the closed set worth modeling as a domain in the first place.

What is a fail-open bug in token validation?

A validation path whose fall-through allows the request. An instanceof ladder that matches the failures it knows and returns 'allowed' for everything else is fail-open: any outcome added later (a revoked token, say) matches none of the checks and sails through the gate. The code compiles, the tests that exist still pass, and the bug is invisible in the diff because the gate's file never changed.

What is an algebraic data type in Java?

A closed set of alternatives, each carrying its own data, that consumers must handle completely. In Java you build one from a sealed interface (the closed set), records (one alternative's data each), and exhaustive switch with record patterns (the checked consumer). Records were final in Java 16 and sealed types in 17, but pattern matching for switch landed last, in 21, which is when the full primitive became available.

Resources

Share this article

Enjoyed this article?

Stay in the Loop

Get notified when I publish new articles. No spam, unsubscribe anytime.

More Posts