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.
- Every class named in
permitsmust directly extend or implement the sealed type. - Every permitted class must be declared
final,sealedornon-sealed. - 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
defaulton 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 inManualSource; 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,sealedornon-sealed; records count asfinal. - 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 ... whenbranches 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
- JEP 441: Pattern Matching for switch, https://openjdk.org/jeps/441
- JEP 440: Record Patterns, https://openjdk.org/jeps/440
java.lang.Class#isSealedand#getPermittedSubclasses, Java SE 21 API documentation- Java Language Specification, Java SE 21, the sections on sealed classes and on switch blocks.