JAVA / RECORDS, SEALED TYPES AND ENUMS
Sealed types and a closed set of subtypes
Model a fixed set of alternatives with sealed interfaces and permits, and write default-free switches whose completeness the compiler checks for you.
What you will learn
- Close a hierarchy with sealed/permits and mark subtypes final, sealed or non-sealed
- Drop default from a switch and let the compiler prove every subtype is handled
- Reopen a single branch with non-sealed without losing exhaustiveness above it
- Build multi-level closed sets by making a permitted subtype sealed itself
Understanding Sealed types and a closed set of subtypes
The sealed modifier sits between final, which forbids extension entirely, and an ordinary public type, which anyone may extend. Writing sealed interface Expr permits Num, Add, Mul, Neg declares that the direct implementations are exactly those four types, and javac stores that list in the class file as a PermittedSubclasses attribute. The restriction therefore survives compilation: another project compiling against your jar gets a compile error for an unlisted subtype, and if a class file is patched to extend Expr anyway, the JVM refuses it at load time with IncompatibleClassChangeError.
The mental model is a closed set of shapes rather than a closed set of values. An enum fixes which constants exist; a sealed type fixes which types exist, and each one is free to carry completely different data, so Num holds an int while Add holds two sub-expressions. Because the compiler can enumerate that set, it can prove that a switch with one case per subtype handles every possible value, which is why eval below needs no default branch. That inverts the maintenance burden: add a Div record to the permits clause and every switch that forgot about division becomes a compile error, and if a consumer is never recompiled the JVM throws MatchException rather than quietly picking a wrong branch.
The rules around sealing exist so that the list can be trusted. Each permitted subtype must directly extend or implement the sealed type and must itself be final, sealed or non-sealed, so no unnamed class can slip in underneath; records satisfy this for free because they are implicitly final. All permitted subtypes must be visible to the compiler together, meaning the same module, or the same package when you are on the plain classpath, and the permits clause may be omitted when they all live in the same file. non-sealed is the deliberate escape valve: it reopens one branch to outside extension while the parent stays closed, and switches on the parent remain exhaustive because a subclass of a permitted type still matches that type's pattern.
public class Expressions {
static int eval(Expr e) {
return switch (e) { // no default: Expr's subtypes are a closed set
case Num num -> num.value();
case Add add -> eval(add.left()) + eval(add.right());
case Mul mul -> eval(mul.left()) * eval(mul.right());
case Neg neg -> -eval(neg.operand());
};
}
public static void main(String[] args) {
Expr e = new Add(new Num(2), new Mul(new Num(5), new Neg(new Num(3))));
System.out.println(e);
System.out.println("value = " + eval(e));
System.out.println("Expr is sealed: " + Expr.class.isSealed());
for (Class<?> sub : Expr.class.getPermittedSubclasses()) {
System.out.println(" permits " + sub.getSimpleName());
}
}
}
sealed interface Expr permits Num, Add, Mul, Neg {}
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
record Neg(Expr operand) implements Expr {}A sealed type publishes the complete list of its direct subtypes in the type system, so "these are all the cases" becomes a fact the compiler and JVM enforce instead of a claim in a comment.
Worked examples
Reopening one branch with non-sealed
Shows how non-sealed lets outside code extend a single subtype while the parent stays closed and the switch stays exhaustive.
public class NonSealedBranch {
static String describe(Vehicle v) {
return switch (v) {
case Car car -> "car";
case Truck truck -> "truck (" + truck.getClass().getSimpleName() + ")";
};
}
public static void main(String[] args) {
System.out.println(describe(new Car()));
System.out.println(describe(new Truck()));
System.out.println(describe(new PickupTruck()));
System.out.println("Vehicle sealed=" + Vehicle.class.isSealed()
+ ", Truck sealed=" + Truck.class.isSealed());
}
}
abstract sealed class Vehicle permits Car, Truck {}
final class Car extends Vehicle {}
non-sealed class Truck extends Vehicle {}
class PickupTruck extends Truck {}Example explained
Line 1Vehicle is abstract as well as sealed, so no value exists that is neither a Car nor a Truck, which is what makes two cases enough.
Line 2non-sealed class Truck reopens that branch, so PickupTruck compiles even though it appears in no permits clause.
Line 3case Truck truck also matches PickupTruck, because a type pattern matches subtypes; exhaustiveness is unaffected by what grows below Truck.
Line 4Truck.class.isSealed() prints false: sealing constrains one type's direct subtypes and does not cascade down the tree.
Letting javac infer the permits clause
Demonstrates that permits may be omitted when every subtype is declared in the same compilation unit.
import java.util.List;
public class InferredPermits {
sealed interface Json { // permits clause omitted on purpose
record Str(String value) implements Json {}
record Num(int value) implements Json {}
record Bool(boolean value) implements Json {}
}
static String render(Json j) {
return switch (j) {
case Json.Str s -> "\"" + s.value() + "\"";
case Json.Num n -> Integer.toString(n.value());
case Json.Bool b -> Boolean.toString(b.value());
};
}
public static void main(String[] args) {
List<Json> values = List.of(new Json.Str("hi"), new Json.Num(42), new Json.Bool(true));
for (Json j : values) {
System.out.println(render(j));
}
System.out.println(Json.class.isSealed()
+ " with " + Json.class.getPermittedSubclasses().length + " permitted subtypes");
}
}Example explained
Line 1Json has no permits clause, yet getPermittedSubclasses().length reports 3: javac inferred the set from the subtypes in this file and wrote it into the class file.
Line 2Records nested in an interface are implicitly static and final, so they need no extra modifier to qualify as permitted subtypes.
Line 3The cases are written Json.Str and Json.Num because those records are members of Json, not of InferredPermits.
Line 4Delete the Json.Bool case and the file stops compiling rather than falling through at runtime, which is the payoff of the closed set.
A two-level closed set
Shows a permitted subtype that is itself sealed, and switches that stay exhaustive at either level of the tree.
public class SealedTree {
sealed interface Event permits UserEvent, SystemEvent {}
sealed interface UserEvent extends Event permits Login, Logout {}
record Login(String user) implements UserEvent {}
record Logout(String user) implements UserEvent {}
record SystemEvent(String message) implements Event {}
static String audit(Event e) {
return switch (e) {
case Login l -> l.user() + " signed in";
case Logout l -> l.user() + " signed out";
case SystemEvent s -> "system: " + s.message();
};
}
static String bucket(Event e) {
return switch (e) {
case UserEvent u -> "user";
case SystemEvent s -> "system";
};
}
public static void main(String[] args) {
Event[] events = { new Login("ada"), new Logout("ada"), new SystemEvent("disk full") };
for (Event e : events) {
System.out.println(bucket(e) + " -> " + audit(e));
}
}
}Example explained
Line 1UserEvent is a permitted subtype of Event and a sealed parent of Login and Logout, so the hierarchy is a tree of closed sets.
Line 2audit compiles without default because the compiler reduces Login plus Logout to UserEvent, then UserEvent plus SystemEvent to Event.
Line 3bucket is exhaustive one level higher with only two cases, so adding a Suspend record to UserEvent breaks audit and leaves bucket untouched.
Line 4Choosing which level to match on decides which future changes will be flagged at compile time.
Important notes
sealed and permits are final features in Java 17, but a default-free switch over a sealed type needs Java 21; on 17 to 20 it was preview, so older compilers still demand a default.
A sealed interface can never be a lambda target and cannot be implemented by an anonymous or local class: there is no name to check against permits, so those forms are compile errors rather than loopholes.
Common mistakes
Adding default -> throw ... to a switch over a sealed type to feel safe: the code compiles today but the exhaustiveness check is gone, so a subtype added next month falls into the default at runtime instead of failing the build.
Writing a permitted subtype as a plain class Card implements Payment {}: javac rejects it with a message asking for sealed, non-sealed or final, because a permitted subtype must either close itself or reopen itself on purpose. Records and enums never hit this since they are implicitly final.
Putting the permits clause in one package and the subtypes in another while on the classpath: the subtype fails to compile because a class in the unnamed module cannot extend a sealed type from a different package.
Try it yourself
Change, predict, then run
In one file, write sealed interface Shape permits Circle, Square with the two records and a default-free area switch. Then add record Triangle(double base, double height) implements Shape plus its entry in permits, and confirm the compiler points at the switch before you add the missing case.
Open the Java workspaceCheck your understanding
A library exposes sealed interface Payment permits Card, Cash, Voucher. A switch expression over Payment already handles all three, and a developer adds default -> throw new IllegalStateException() "just to be safe". What does that default actually change?
- Nothing today, but it disables the exhaustiveness check, so a later Crypto subtype compiles and lands in the default at runtime
- It fails to compile, because a switch over a sealed type may not declare a default branch
- It reopens Payment, letting classes outside the permits clause implement the interface
- It is discarded by javac, which already knows the three cases cover every Payment
Show answer
A default is legal and currently unreachable, but once a switch is total by construction the compiler has nothing left to check, so adding a fourth permitted subtype produces no error and the new case is handled by the throw at runtime. Option 3 is tempting because the branch really is dead code right now, but javac neither removes it nor keeps reporting missing cases; the compile-time safety net is exactly what was traded away.