> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Use Sealed Classes and Interfaces in Java 21
- URL: https://www.ggorantala.dev/how-to-use-sealed-classes-interfaces-java/
- Published: 2026-09-16T05:22:00.000Z
- Updated: 2026-10-31T06:31:37.000Z
- Description: 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.
- Author: Gopi Gorantala
- Tags: how-to-tutorials, modern-java, core-java

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

```log
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:

```log
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.

```bash
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:

```log
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`

```java
package com.example.rates;

public sealed interface RateResult permits Fresh, Stale, Unavailable {

    String pair();
}

```

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

```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`

```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`

```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.

```bash
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`

```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`

```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`

```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`

```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`

```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`

```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.

```bash
javac -d out src/main/java/com/example/rates/*.java
java -cp out com.example.rates.Main

```

```log
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:

```log
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`

```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);
        }
    }
}

```

```bash
javac -d out src/main/java/com/example/rates/*.java src/test/java/com/example/rates/*.java
java -cp out com.example.rates.Checks

```

```log
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](https://ggorantala.dev/how-to-use-java-records/?ref=ggorantala.dev); for why the fields stay immutable, [how to create an immutable class in Java](https://ggorantala.dev/how-to-create-immutable-class-java/?ref=ggorantala.dev) 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.

```log
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.**

```log
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`.

```log
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.

```log
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.

```log
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:

```log
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](https://ggorantala.dev/how-to-use-optional-java/?ref=ggorantala.dev) is the sibling to this one, and [the strategy pattern](https://ggorantala.dev/how-to-implement-strategy-pattern-java/?ref=ggorantala.dev) shows another place a closed set of cases shows up. For the equality rules behind the records, see [equals() and hashCode()](https://ggorantala.dev/how-to-implement-equals-hashcode-java/?ref=ggorantala.dev), and [the builder pattern](https://ggorantala.dev/how-to-implement-builder-pattern-java/?ref=ggorantala.dev) 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.

## Sealed class questions people search

**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

- JEP 409: Sealed Classes, [https://openjdk.org/jeps/409](https://openjdk.org/jeps/409?ref=ggorantala.dev)
- JEP 441: Pattern Matching for switch, [https://openjdk.org/jeps/441](https://openjdk.org/jeps/441?ref=ggorantala.dev)
- JEP 440: Record Patterns, [https://openjdk.org/jeps/440](https://openjdk.org/jeps/440?ref=ggorantala.dev)
- `java.lang.Class#isSealed` and `#getPermittedSubclasses`, Java SE 21 API documentation
- Java Language Specification, Java SE 21, the sections on sealed classes and on switch blocks.