Skip to content

How to Use Sealed Classes and Interfaces in Java 21

Build a currency-rate feed in Java 21 where the compiler flags every switch you forgot to update. Sealed interfaces, records and sealed classes, step by step.

how-to-tutorials modern-java core-java
Gopi Gorantala
Reading Progress

On This Page

Add one new record to a hierarchy, recompile, and get this:

src/main/java/com/example/rates/Display.java:15: error: the switch expression does not cover all possible input values
        return switch (result) {
               ^
src/main/java/com/example/rates/Display.java:26: error: the switch expression does not cover all possible input values
        return switch (result) {
               ^
2 errors

That's two switches in a codebase telling you they haven't heard about the new case yet. Nobody had to remember to grep for them. That's what sealed types buy you, and this article shows how to use sealed classes in Java by building a small currency-rate feed where every quote is exactly one of three things.

This is what the finished program prints:

EUR/USD  1.0842  fresh   tradable=true
CHF/USD  --  unavailable: pair not quoted by the live feed   tradable=false
GBP/USD  1.2710  stale (42 min old)   tradable=false
JPY/USD  0.0066  VERY STALE (5 h old)   tradable=false

Four quotes, four outcomes: fresh, unavailable, stale, and so stale it gets its own warning. Only the first one is allowed to price a trade. You'll build all of it below, with the full source of every file. It's for Java developers who know classes, interfaces and records, and want to stop writing default -> branches that quietly swallow new cases.

The feed is simulated. Rates come from in-memory maps and a fixed clock, so your output matches mine character for character. No network, no database, no container.

Check your setup

Tool Version I used Check it with
JDK OpenJDK 21.0.12.1 (Ubuntu package) java -version
javac 21.0.12.1 javac -version
Build tool none, plain javac
IDE none for the build; notes for IntelliJ IDEA and Eclipse below, read from docs, not executed

You need Java 17 or newer for sealed types, and 21 for the pattern-matching switch this article uses. If javac -version prints anything lower than 21, install a newer JDK first; SDKMAN works well for that (sdk install java and pick a 21 build).

I ran everything on JDK 21 only. I didn't try JDK 25. Sealed types haven't changed in a way that should affect this code, but I haven't run it, so I won't claim it.

Lay out the rates project

Make the folders and open them in whatever you like.

mkdir -p rates/src/main/java/com/example/rates rates/src/test/java/com/example/rates
cd rates

When we're done, the tree looks like this:

rates
└── src
    ├── main/java/com/example/rates
    │   ├── RateResult.java      sealed interface
    │   ├── Fresh.java           record
    │   ├── Stale.java           record
    │   ├── Unavailable.java     record
    │   ├── RateSource.java      sealed abstract class
    │   ├── LiveSource.java      final
    │   ├── CachedSource.java    final
    │   ├── ManualSource.java    non-sealed
    │   ├── Display.java         the switches
    │   └── Main.java
    └── test/java/com/example/rates
        └── Checks.java

In IntelliJ IDEA

Create a new Java project (File → New → Project), pick JDK 21, and copy the com/example/rates folders under src. If the editor underlines sealed as an error, open File → Project Structure → Project and set the language level to 21. I didn't open IntelliJ for this run; the menu path comes from JetBrains' docs.

In Eclipse

Create a Java project, choose a JRE 21, and set the compiler compliance to 21 under Project → Properties → Java Compiler. Same caveat: not executed here. If sealed still shows red squiggles after you set the compliance, check that your Eclipse release supports Java 21; I haven't verified which release added that.

Build tools

I didn't run Maven or Gradle for this one. Maven Central wasn't reachable from my environment, and there's nothing here that needs a dependency. If you do use one, set the Java release to 21 (maven.compiler.release in Maven, java.toolchain in Gradle).

How to use sealed classes in Java: four steps

The idea is simple to state. A sealed type lists every class that may extend it. The compiler knows the list, so it can tell you when a switch leaves one out.

Step 1: Define the closed set of outcomes

Goal: say, in code, that a rate lookup has exactly three possible results.

src/main/java/com/example/rates/RateResult.java

package com.example.rates;

public sealed interface RateResult permits Fresh, Stale, Unavailable {

    String pair();
}

src/main/java/com/example/rates/Fresh.java

package com.example.rates;

import java.math.BigDecimal;
import java.time.Instant;

public record Fresh(String pair, BigDecimal rate, Instant fetchedAt) implements RateResult {
}

src/main/java/com/example/rates/Stale.java

package com.example.rates;

import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;

public record Stale(String pair, BigDecimal rate, Instant fetchedAt, Duration age) implements RateResult {
}

src/main/java/com/example/rates/Unavailable.java

package com.example.rates;

public record Unavailable(String pair, String reason) implements RateResult {
}

The permits clause is the whole trick. Nothing outside that list can implement RateResult. You don't strictly need it when the subtypes live in the same file as the sealed type, because the compiler infers the list there, but separate files make the list visible, and I like that it's written down.

Each permitted subtype must say what happens to its own subclasses: final, sealed or non-sealed. Records are implicitly final, so these three satisfy the rule with no keyword at all.

Checkpoint: compile these four files on their own.

mkdir -p out
javac -d out src/main/java/com/example/rates/RateResult.java src/main/java/com/example/rates/Fresh.java src/main/java/com/example/rates/Stale.java src/main/java/com/example/rates/Unavailable.java

No output means it compiled.

Step 2: Add a sealed class for the rate sources

Goal: the same idea with an abstract class, and a look at all three modifiers side by side.

src/main/java/com/example/rates/RateSource.java

package com.example.rates;

import java.time.Clock;

public abstract sealed class RateSource permits LiveSource, CachedSource, ManualSource {

    protected final Clock clock;

    protected RateSource(Clock clock) {
        this.clock = clock;
    }

    public abstract RateResult fetch(String pair);

    protected static RateResult missing(String pair, String why) {
        return new Unavailable(pair, why);
    }
}

src/main/java/com/example/rates/LiveSource.java

package com.example.rates;

import java.math.BigDecimal;
import java.time.Clock;
import java.util.Map;

/** Pretends to be a live feed: quotes come from an in-memory map, stamped with "now". */
public final class LiveSource extends RateSource {

    private final Map<String, BigDecimal> quotes;

    public LiveSource(Clock clock, Map<String, BigDecimal> quotes) {
        super(clock);
        this.quotes = Map.copyOf(quotes);
    }

    @Override
    public RateResult fetch(String pair) {
        BigDecimal rate = quotes.get(pair);
        if (rate == null) {
            return missing(pair, "pair not quoted by the live feed");
        }
        return new Fresh(pair, rate, clock.instant());
    }
}

src/main/java/com/example/rates/CachedSource.java

package com.example.rates;

import java.math.BigDecimal;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;

/** Serves the last rates we stored. Anything older than the freshness window comes back Stale. */
public final class CachedSource extends RateSource {

    public record Entry(BigDecimal rate, Instant storedAt) {
    }

    private final Map<String, Entry> cache;
    private final Duration freshFor;

    public CachedSource(Clock clock, Duration freshFor, Map<String, Entry> cache) {
        super(clock);
        this.freshFor = freshFor;
        this.cache = Map.copyOf(cache);
    }

    @Override
    public RateResult fetch(String pair) {
        Entry e = cache.get(pair);
        if (e == null) {
            return missing(pair, "nothing cached for this pair");
        }
        Duration age = Duration.between(e.storedAt(), clock.instant());
        if (age.compareTo(freshFor) <= 0) {
            return new Fresh(pair, e.rate(), e.storedAt());
        }
        return new Stale(pair, e.rate(), e.storedAt(), age);
    }
}

src/main/java/com/example/rates/ManualSource.java

package com.example.rates;

import java.math.BigDecimal;
import java.time.Clock;
import java.util.HashMap;
import java.util.Map;

/** Rates typed in by a desk operator. Left open (non-sealed) so tests can subclass it. */
public non-sealed class ManualSource extends RateSource {

    private final Map<String, BigDecimal> typedIn = new HashMap<>();

    public ManualSource(Clock clock) {
        super(clock);
    }

    public void set(String pair, BigDecimal rate) {
        typedIn.put(pair, rate);
    }

    @Override
    public RateResult fetch(String pair) {
        BigDecimal rate = typedIn.get(pair);
        return rate == null ? missing(pair, "no manual rate entered") : new Fresh(pair, rate, clock.instant());
    }
}

Three sources, three different answers to "who may extend you?". LiveSource and CachedSource are final: closed, done. ManualSource is non-sealed: it opts back out of the restriction, so anyone can subclass it, which is what the test later does. RateSource itself stays closed to the outside world, because the only door is its permits list.

My first draft had ManualSource as final too, and then I wanted a fake that simulates an outage in the tests. That's the realistic reason to reach for non-sealed: one branch of the hierarchy that really does need to be open.

Step 3: Render results with an exhaustive switch

Goal: one place that turns any RateResult into a line of text, plus one that decides whether a quote may price a trade.

src/main/java/com/example/rates/Display.java

package com.example.rates;

import java.math.BigDecimal;
import java.time.Duration;
import java.util.Optional;

public final class Display {

    private static final Duration VERY_STALE = Duration.ofHours(1);

    private Display() {
    }

    public static String line(RateResult result) {
        return switch (result) {
            case Fresh f -> "%s  %s  fresh".formatted(f.pair(), f.rate().toPlainString());
            case Stale s when s.age().compareTo(VERY_STALE) > 0 ->
                    "%s  %s  VERY STALE (%d h old)".formatted(s.pair(), s.rate().toPlainString(), s.age().toHours());
            case Stale s -> "%s  %s  stale (%d min old)".formatted(s.pair(), s.rate().toPlainString(), s.age().toMinutes());
            case Unavailable u -> "%s  --  unavailable: %s".formatted(u.pair(), u.reason());
        };
    }

    /** The rate you may price a trade with: only a fresh quote qualifies. */
    public static Optional<BigDecimal> tradableRate(RateResult result) {
        return switch (result) {
            case Fresh(String pair, BigDecimal rate, var fetchedAt) -> Optional.of(rate);
            case Stale s -> Optional.empty();
            case Unavailable u -> Optional.empty();
        };
    }
}

Three things are going on here that are worth a second look.

First, there's no default. The switch covers Fresh, Stale and Unavailable, and the compiler knows those are all there are, so it accepts that. Remove a case and it refuses to compile (see the troubleshooting section).

Second, case Stale s when s.age().compareTo(VERY_STALE) > 0 is a guarded pattern. It has to come before the plain case Stale s, or the compiler rejects the plain one as dominating it.

Third, case Fresh(String pair, BigDecimal rate, var fetchedAt) is a record pattern, a Java 21 feature that pulls the components out in one go. I use it once so you've seen it; s.age() style accessors work equally well. There's a separate article on record patterns coming.

Step 4: Add a Main that exercises all four outcomes

Goal: run something you can see.

src/main/java/com/example/rates/Main.java

package com.example.rates;

import java.math.BigDecimal;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;

public final class Main {

    public static void main(String[] args) {
        Instant now = Instant.parse("2026-10-25T09:00:00Z");
        Clock clock = Clock.fixed(now, ZoneOffset.UTC);

        RateSource live = new LiveSource(clock, Map.of("EUR/USD", new BigDecimal("1.0842")));
        RateSource cached = new CachedSource(clock, Duration.ofMinutes(5), Map.of(
                "GBP/USD", new CachedSource.Entry(new BigDecimal("1.2710"), now.minus(Duration.ofMinutes(42))),
                "JPY/USD", new CachedSource.Entry(new BigDecimal("0.0066"), now.minus(Duration.ofHours(5)))));

        List<RateResult> results = List.of(
                live.fetch("EUR/USD"),
                live.fetch("CHF/USD"),
                cached.fetch("GBP/USD"),
                cached.fetch("JPY/USD"));

        for (RateResult r : results) {
            System.out.println(Display.line(r) + "   tradable=" + Display.tradableRate(r).isPresent());
        }
    }
}

Checkpoint: compile everything and run it.

javac -d out src/main/java/com/example/rates/*.java
java -cp out com.example.rates.Main
EUR/USD  1.0842  fresh   tradable=true
CHF/USD  --  unavailable: pair not quoted by the live feed   tradable=false
GBP/USD  1.2710  stale (42 min old)   tradable=false
JPY/USD  0.0066  VERY STALE (5 h old)   tradable=false

If you see those four lines, the hard part's done. The 42-minute-old GBP quote is stale but usable for display; the five-hour-old yen quote crosses the one-hour line. Neither is tradable.

Watch it classify four quotes

Change one thing and run it again. In Main, switch the cache window from Duration.ofMinutes(5) to Duration.ofHours(1), recompile, and rerun. I did, and the GBP line changed to this:

GBP/USD  1.2710  fresh   tradable=true

The yen line stays very stale. Nothing outside CachedSource changed, because the decision lives there and the rest of the program only sees the result type.

Pin it down with checks

I couldn't reach Maven Central from my environment, so there's no JUnit in this project. The checks below are plain Java with a tiny check helper that prints PASS or FAIL. If you use JUnit 5, each check(...) becomes an assertTrue or, better, an AssertJ assertion; the logic carries over unchanged.

src/test/java/com/example/rates/Checks.java

package com.example.rates;

import java.math.BigDecimal;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;

public final class Checks {

    private static final Instant NOW = Instant.parse("2026-10-25T09:00:00Z");
    private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
    private static int passed;
    private static int failed;

    public static void main(String[] args) {
        liveFeedGivesFreshOrUnavailable();
        cacheAgesIntoStale();
        veryStaleGetsItsOwnLine();
        onlyFreshIsTradable();
        reflectionSeesTheHierarchy();
        nonSealedSourceCanBeSubclassed();
        System.out.printf("%n%d passed, %d failed%n", passed, failed);
        if (failed > 0) {
            System.exit(1);
        }
    }

    static void liveFeedGivesFreshOrUnavailable() {
        RateSource live = new LiveSource(CLOCK, Map.of("EUR/USD", new BigDecimal("1.0842")));
        check("live: quoted pair is Fresh", live.fetch("EUR/USD") instanceof Fresh f
                && f.rate().equals(new BigDecimal("1.0842")) && f.fetchedAt().equals(NOW));
        check("live: unknown pair is Unavailable", live.fetch("CHF/USD") instanceof Unavailable u
                && u.reason().contains("not quoted"));
    }

    static void cacheAgesIntoStale() {
        RateSource cache = new CachedSource(CLOCK, Duration.ofMinutes(5), Map.of(
                "A/USD", new CachedSource.Entry(BigDecimal.ONE, NOW.minus(Duration.ofMinutes(5))),
                "B/USD", new CachedSource.Entry(BigDecimal.ONE, NOW.minus(Duration.ofMinutes(5)).minusSeconds(1))));
        check("cache: exactly at the window is still Fresh", cache.fetch("A/USD") instanceof Fresh);
        check("cache: one second past the window is Stale",
                cache.fetch("B/USD") instanceof Stale s && s.age().equals(Duration.ofMinutes(5).plusSeconds(1)));
        check("cache: missing pair is Unavailable", cache.fetch("C/USD") instanceof Unavailable);
    }

    static void veryStaleGetsItsOwnLine() {
        RateResult hour = new Stale("X/USD", BigDecimal.TEN, NOW, Duration.ofHours(1));
        RateResult hourAndOne = new Stale("X/USD", BigDecimal.TEN, NOW, Duration.ofHours(1).plusMinutes(1));
        check("display: exactly 1 h is plain stale", Display.line(hour).contains("stale (60 min old)"));
        check("display: past 1 h is VERY STALE", Display.line(hourAndOne).contains("VERY STALE (1 h old)"));
        check("display: guard did not swallow plain stale", !Display.line(hour).contains("VERY"));
    }

    static void onlyFreshIsTradable() {
        List<RateResult> all = List.of(
                new Fresh("A", BigDecimal.ONE, NOW),
                new Stale("A", BigDecimal.ONE, NOW, Duration.ofMinutes(9)),
                new Unavailable("A", "down"));
        List<Boolean> tradable = all.stream().map(r -> Display.tradableRate(r).isPresent()).toList();
        check("tradable: [true, false, false]", tradable.equals(List.of(true, false, false)));
        check("tradable: Fresh hands back the same BigDecimal",
                Display.tradableRate(all.get(0)).equals(Optional.of(BigDecimal.ONE)));
    }

    static void reflectionSeesTheHierarchy() {
        check("RateResult.isSealed()", RateResult.class.isSealed());
        List<String> names = Arrays.stream(RateResult.class.getPermittedSubclasses())
                .map(Class::getSimpleName).toList();
        check("permitted subclasses in declared order: " + names,
                names.equals(List.of("Fresh", "Stale", "Unavailable")));
        check("records are final", java.lang.reflect.Modifier.isFinal(Fresh.class.getModifiers()));
        check("RateSource.isSealed()", RateSource.class.isSealed());
        check("ManualSource is not sealed", !ManualSource.class.isSealed());
        check("LiveSource is not sealed (final)", !LiveSource.class.isSealed());
    }

    static void nonSealedSourceCanBeSubclassed() {
        class OutageSource extends ManualSource {
            OutageSource() {
                super(CLOCK);
            }

            @Override
            public RateResult fetch(String pair) {
                return new Unavailable(pair, "simulated outage");
            }
        }
        RateResult r = new OutageSource().fetch("EUR/USD");
        check("non-sealed ManualSource: test subclass works", r instanceof Unavailable u
                && u.reason().equals("simulated outage"));
    }

    private static void check(String name, boolean ok) {
        if (ok) {
            passed++;
            System.out.println("PASS  " + name);
        } else {
            failed++;
            System.out.println("FAIL  " + name);
        }
    }
}
javac -d out src/main/java/com/example/rates/*.java src/test/java/com/example/rates/*.java
java -cp out com.example.rates.Checks
PASS  live: quoted pair is Fresh
PASS  live: unknown pair is Unavailable
PASS  cache: exactly at the window is still Fresh
PASS  cache: one second past the window is Stale
PASS  cache: missing pair is Unavailable
PASS  display: exactly 1 h is plain stale
PASS  display: past 1 h is VERY STALE
PASS  display: guard did not swallow plain stale
PASS  tradable: [true, false, false]
PASS  tradable: Fresh hands back the same BigDecimal
PASS  RateResult.isSealed()
PASS  permitted subclasses in declared order: [Fresh, Stale, Unavailable]
PASS  records are final
PASS  RateSource.isSealed()
PASS  ManualSource is not sealed
PASS  LiveSource is not sealed (final)
PASS  non-sealed ManualSource: test subclass works

17 passed, 0 failed

Two of those are boundary checks I'd keep in any version: exactly five minutes old is still fresh, one second more is stale; exactly one hour is plain stale, one minute more is very stale. Guards are where off-by-one bugs hide.

The reflection checks are the other interesting ones. Class.isSealed() and Class.getPermittedSubclasses() (both in the JDK since 17) let you assert the shape of the hierarchy itself. If somebody later adds a fourth variant, a test like this turns a quiet design change into a visible diff.

What javac checks on a sealed type

When you write sealed ... permits A, B, C, javac enforces three rules at compile time, and the JVM enforces them again when classes load.

  1. Every class named in permits must directly extend or implement the sealed type.
  2. Every permitted class must be declared final, sealed or non-sealed.
  3. Permitted classes must live in the same module as the sealed type, or in the same package if you're on the classpath (the unnamed module).

The payoff is exhaustiveness. For a switch over a sealed type, javac collects the permitted subclasses, checks that your patterns cover each of them, and accepts the switch without a default when they do. That's also why default is a trap on a sealed type: it makes the switch exhaustive no matter what, so the compiler stops telling you about new variants.

Records and sealed interfaces fit together neatly because a record is a final, immutable data carrier. A sealed interface of records is as close as Java gets to an algebraic data type, a "sum of products". If records are new to you, start with how to use Java records; for why the fields stay immutable, how to create an immutable class in Java covers the rules.

Compiler errors I forced, and what they mean

I broke the project eight ways on purpose. These are the ones you'll meet.

You added a variant and didn't update a switch. I added a Rejected record and put it in the permits list.

src/main/java/com/example/rates/Display.java:15: error: the switch expression does not cover all possible input values
        return switch (result) {
               ^
src/main/java/com/example/rates/Display.java:26: error: the switch expression does not cover all possible input values
        return switch (result) {
               ^
2 errors

Cause: the switches are exhaustive over the old list. Fix: add a case Rejected r -> branch to each. This is the error you want. It's the feature.

You implemented the sealed interface and forgot the permits entry.

src/main/java/com/example/rates/Rejected.java:3: error: class is not allowed to extend sealed class: RateResult (as it is not listed in its 'permits' clause)
public record Rejected(String pair, String reason) implements RateResult {
       ^
1 error

Cause: Rejected implements RateResult but isn't listed. Fix: add it to permits, or move it into the same file as the interface, where the list is inferred.

A permitted class has no modifier. I deleted final from LiveSource.

src/main/java/com/example/rates/LiveSource.java:8: error: sealed, non-sealed or final modifiers expected
public class LiveSource extends RateSource {
       ^
1 error

Cause: rule 2 above. A plain class doesn't say whether it's open or closed, so javac makes you pick. Fix: final, sealed or non-sealed.

A permitted class in another package. I moved a variant to com.example.other and listed it.

src/main/java/com/example/rates/RateResult.java:3: error: class RateResult in unnamed module cannot extend a sealed class in a different package
public sealed interface RateResult permits Fresh, Stale, Unavailable, com.example.other.Odd {
                                                                                       ^
1 error

Cause: on the classpath everything is in the unnamed module, and sealed types can't span packages there. Fix: put the types in one package, or use a named module with a module-info.java.

You added default to keep the compiler quiet. With default -> added to both switches and the Rejected variant in place, the project compiled without a single warning and ran. A Rejected result would have printed ? and been treated as non-tradable, silently. Fix: remove default. If you want a catch-all for a reason, write the explicit cases anyway so the compiler still has a say.

The unguarded case comes first. I swapped the two Stale cases.

src/main/java/com/example/rates/Display.java:18: error: this case label is dominated by a preceding case label
            case Stale s when s.age().compareTo(VERY_STALE) > 0 ->
                 ^
1 error

Cause: the plain case Stale s matches everything the guarded one would, so the guarded one can never run. Fix: guarded cases first, plain case after.

A case removed outright gives the same "does not cover all possible input values" message as the first entry, which reads oddly the first time, since you didn't add anything. The message means "not every permitted type has a branch", whichever way you got there.

Old Java. On --release 11, javac complains before it gets to sealed. The first lines:

src/main/java/com/example/rates/CachedSource.java:12: error: records are not supported in -source 11
    public record Entry(BigDecimal rate, Instant storedAt) {
           ^
  (use -source 16 or higher to enable records)
src/main/java/com/example/rates/CachedSource.java:12: warning: 'record' may become a restricted type name in a future release and may be unusable for type declarations or as the element type of an array
    public record Entry(BigDecimal rate, Instant storedAt) {
           ^
src/main/java/com/example/rates/Display.java:15: error: switch expressions are not supported in -source 11
        return switch (result) {

Cause: records, switch expressions, patterns and sealed types are all newer than 11. Pattern switch was a preview feature from 17 through 20 and became final in 21. I didn't test any of those releases, so if you're on one, check the JEPs below before assuming anything.

Version notes and next steps

Sealed classes and interfaces became final in Java 17 (JEP 409). Pattern matching for switch became final in 21 (JEP 441), along with record patterns (JEP 440). So on 17 you can seal a hierarchy and use instanceof chains over it, but the exhaustive switch needs 21.

A few opinions from using these:

  • Seal when you own every subtype. Result types, AST nodes, commands, state machines. Don't seal a type that third parties are meant to implement; that's what interfaces are for.
  • Prefer records for the variants. They're concise and final, and the record pattern gives you deconstruction for free.
  • Skip default on sealed switches. I'd make this a code-review rule.
  • Be careful with non-sealed. It reopens the hierarchy at that branch, and exhaustiveness no longer sees past it. It earned its place in ManualSource; use it rarely.

Also watch binary compatibility. If you publish a library with a sealed type and add a permitted subclass later, every client that switches over it breaks at compile time. For a library, that's usually a major-version change, which is a feature if you meant it and a surprise if you didn't.

Next in this series, I'm planning the switch-expressions article (how-to-use-switch-expressions-pattern-matching-java), the record-patterns article (how-to-use-record-patterns-java) and the JUnit 5 one (how-to-write-unit-tests-junit-5-java). Those slugs aren't live yet. If you handle absence rather than outcomes, how to use Optional in Java is the sibling to this one, and the strategy pattern shows another place a closed set of cases shows up. For the equality rules behind the records, see equals() and hashCode(), and the builder pattern when a variant has many fields.

Rules to keep

  • Write sealed ... permits ... on the type that should have a closed set of subtypes.
  • Give every permitted subtype final, sealed or non-sealed; records count as final.
  • Keep sealed types in one package (or one module).
  • Switch over a sealed type without default, and let the compiler find the cases you forgot.
  • Put guarded case ... when branches before the unguarded one for the same type.

What is the difference between sealed and final in Java? final forbids any subclass. sealed allows a fixed, named list of subclasses and forbids everything else. Each permitted subclass then picks its own modifier.

Can a sealed class be extended? Yes, by the classes in its permits clause, and only those. Those classes must be final, sealed or non-sealed.

Do sealed classes need Java 17? Yes, 17 is the first release with sealed classes as a final feature. The exhaustive switch over them needs 21 for pattern matching.

Can an enum implement a sealed interface? Yes. Enums are implicitly final (or sealed if they have constant bodies), so they satisfy the modifier rule. I haven't built that case in this project, so check the Javadoc and the language spec for the details.

References

how-to-tutorialsmodern-javacore-java

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments