Java · lesson 34 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Also part of: Low-Level Design
Sealed Classes and Interfaces in Java
Learn sealed classes and interfaces (Java 17+) by running them: permits, final vs non-sealed subtypes, exhaustive switch, record patterns and guards.
By SkillByExample · Updated
A sealed class or interface lists exactly which types may extend it. That sits between an open
class (anyone can extend it) and a final one (no one can), and it gives the compiler a complete
list of subtypes to check switch statements against. Every example is a single file run with the
JDK 21 source launcher (java File.java).
Declaring a sealed hierarchy
public class VehicleDemo {
sealed interface Vehicle permits Car, Truck, Motorcycle {}
record Car(int doors) implements Vehicle {} // records are implicitly final
static final class Truck implements Vehicle {
double payloadTons() { return 12.5; }
}
static non-sealed class Motorcycle implements Vehicle {} // reopens this branch
static class Scooter extends Motorcycle {} // allowed: Motorcycle is non-sealed
public static void main(String[] args) {
Vehicle[] fleet = { new Car(4), new Truck(), new Scooter() };
for (Vehicle v : fleet)
System.out.println(v.getClass().getSimpleName() + " is a Vehicle");
System.out.println(java.util.Arrays.toString(Vehicle.class.getPermittedSubclasses()));
System.out.println(Vehicle.class.isSealed());
}
}
$ java VehicleDemo.java
Car is a Vehicle
Truck is a Vehicle
Scooter is a Vehicle
[class VehicleDemo$Car, class VehicleDemo$Truck, class VehicleDemo$Motorcycle]
true
Each permitted subtype must state how the hierarchy continues: final (records are implicitly
final), sealed with its own permits, or non-sealed, which lets anything extend that branch —
so Scooter compiles. Reflection exposes the closed list through getPermittedSubclasses().
What the compiler rejects
public class NotPermitted {
sealed interface Shape permits Circle, Square {}
record Circle(double r) implements Shape {}
record Square(double side) implements Shape {}
record Hexagon(double side) implements Shape {}
public static void main(String[] args) {
System.out.println(new Hexagon(1));
}
}
$ java NotPermitted.java
NotPermitted.java:5: error: class is not allowed to extend sealed class: Shape (as it is not listed in its 'permits' clause)
record Hexagon(double side) implements Shape {}
^
1 error
error: compilation failed
Hexagon is not in the permits list, so the program never compiles — the source launcher stops
at the error. That is the whole point of sealing: the set of shapes is closed and the compiler
enforces it, not a convention in a comment.
Exhaustive switch with no default
public class PaymentDemo {
sealed interface Payment permits CreditCard, BankTransfer, Crypto {}
record CreditCard(String number, double amount) implements Payment {}
record BankTransfer(String iban, double amount) implements Payment {}
record Crypto(String currency, double amount) implements Payment {}
static double fee(Payment p) {
return switch (p) {
case CreditCard cc -> cc.amount() * 0.025;
case BankTransfer bt -> bt.amount() * 0.005;
case Crypto c -> c.amount() * 0.01;
};
}
static String describe(Payment p) {
return switch (p) {
case CreditCard(var number, var amount) -> "Card ending " + number.substring(number.length() - 4) + " for $" + amount;
case BankTransfer(var iban, var amount) -> "Transfer from " + iban + " for $" + amount;
case Crypto(var currency, var amount) -> amount + " " + currency;
};
}
public static void main(String[] args) {
Payment[] payments = { new CreditCard("4111111111111234", 100), new BankTransfer("DE89 3704", 2000), new Crypto("BTC", 0.5) };
for (Payment p : payments)
System.out.printf("%-36s fee %.4f%n", describe(p), fee(p));
}
}
$ java PaymentDemo.java
Card ending 1234 for $100.0 fee 2.5000
Transfer from DE89 3704 for $2000.0 fee 10.0000
0.5 BTC fee 0.0050
Neither switch has a default: the compiler knows Payment has exactly three implementations and
checks that the cases cover them. describe uses record patterns (Java 21) to destructure each
record in the case itself. The payoff comes when the hierarchy grows, as the next example shows.
Adding a subtype breaks every incomplete switch
public class MissingCase {
sealed interface Payment permits CreditCard, BankTransfer, PayPal {}
record CreditCard(double amount) implements Payment {}
record BankTransfer(double amount) implements Payment {}
record PayPal(String email, double amount) implements Payment {}
static double fee(Payment p) {
return switch (p) {
case CreditCard cc -> cc.amount() * 0.025;
case BankTransfer bt -> bt.amount() * 0.005;
};
}
public static void main(String[] args) {
System.out.println(fee(new PayPal("a@b.com", 10)));
}
}
$ java MissingCase.java
MissingCase.java:8: error: the switch expression does not cover all possible input values
return switch (p) {
^
1 error
error: compilation failed
Adding PayPal to permits turned an old, forgotten switch into a compile error. With an open
interface and a default branch, the same change would compile and quietly charge PayPal the
default fee. This is why a default is best left out of switches over sealed types.
Modelling state with sealed records
public class OrderDemo {
sealed interface Status permits Pending, Processing, Shipped, Cancelled {}
record Pending() implements Status {}
record Processing(String warehouse) implements Status {}
record Shipped(String tracking) implements Status {}
record Cancelled(String reason) implements Status {}
static Status process(Status s, String warehouse) {
if (s instanceof Pending) return new Processing(warehouse);
throw new IllegalStateException("Cannot process order in " + s);
}
static String summary(Status s) {
return switch (s) {
case Pending p -> "Waiting for a warehouse";
case Processing(var wh) -> "Processing at " + wh;
case Shipped(var t) -> "Tracking: " + t;
case Cancelled(var why) -> "Cancelled: " + why;
};
}
public static void main(String[] args) {
Status s = new Pending();
System.out.println(summary(s));
s = process(s, "WH-EAST-1");
System.out.println(summary(s));
try {
process(s, "WH-WEST-2");
} catch (IllegalStateException e) {
System.out.println("Caught: " + e.getMessage());
}
}
}
$ java OrderDemo.java
Waiting for a warehouse
Processing at WH-EAST-1
Caught: Cannot process order in Processing[warehouse=WH-EAST-1]
Each state carries only the data that makes sense in that state — a tracking number exists only on
Shipped. process allows just the Pending -> Processing transition, so the second call fails
and the exception message includes the record’s generated toString.
Recursive data: an expression tree
public class ExprDemo {
sealed interface Expr permits Num, Add, Mul, Neg {}
record Num(double value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
record Neg(Expr expr) implements Expr {}
static double eval(Expr e) {
return switch (e) {
case Num(var v) -> v;
case Add(var l, var r) -> eval(l) + eval(r);
case Mul(var l, var r) -> eval(l) * eval(r);
case Neg(var inner) -> -eval(inner);
};
}
static String show(Expr e) {
return switch (e) {
case Num(var v) -> String.valueOf(v);
case Add(var l, var r) -> "(" + show(l) + " + " + show(r) + ")";
case Mul(var l, var r) -> "(" + show(l) + " * " + show(r) + ")";
case Neg(var inner) -> "-" + show(inner);
};
}
public static void main(String[] args) {
Expr e = new Mul(new Add(new Num(3), new Num(4)), new Neg(new Num(2)));
System.out.println(show(e) + " = " + eval(e));
}
}
$ java ExprDemo.java
((3.0 + 4.0) * -2.0) = -14.0
eval and show are two independent operations over the same closed set of node types. Adding an
operation means writing one more switch; adding a node type makes the compiler point at every
switch that needs a new case.
Sealed types vs the Visitor pattern
Before sealed types, the textbook way to run several operations over a closed set of node types was
the GoF Visitor pattern. Here is the same tree, written as three files, with an accept method
on every node. Put these in their own directory, since Expr here is not the sealed Expr above.
// Expr.java
interface ExprVisitor<R> {
R visitNum(Num n);
R visitAdd(Add a);
R visitMul(Mul m);
R visitNeg(Neg n);
}
interface Expr {
<R> R accept(ExprVisitor<R> v);
}
record Num(double value) implements Expr {
public <R> R accept(ExprVisitor<R> v) { return v.visitNum(this); }
}
record Add(Expr left, Expr right) implements Expr {
public <R> R accept(ExprVisitor<R> v) { return v.visitAdd(this); }
}
record Mul(Expr left, Expr right) implements Expr {
public <R> R accept(ExprVisitor<R> v) { return v.visitMul(this); }
}
record Neg(Expr expr) implements Expr {
public <R> R accept(ExprVisitor<R> v) { return v.visitNeg(this); }
}
Each operation is a class that implements the visitor interface, with one method per node type.
// Visitors.java
class Eval implements ExprVisitor<Double> {
public Double visitNum(Num n) { return n.value(); }
public Double visitAdd(Add a) { return a.left().accept(this) + a.right().accept(this); }
public Double visitMul(Mul m) { return m.left().accept(this) * m.right().accept(this); }
public Double visitNeg(Neg n) { return -n.expr().accept(this); }
}
class Show implements ExprVisitor<String> {
public String visitNum(Num n) { return String.valueOf(n.value()); }
public String visitAdd(Add a) { return "(" + a.left().accept(this) + " + " + a.right().accept(this) + ")"; }
public String visitMul(Mul m) { return "(" + m.left().accept(this) + " * " + m.right().accept(this) + ")"; }
public String visitNeg(Neg n) { return "-" + n.expr().accept(this); }
}
// Main.java
public class Main {
public static void main(String[] args) {
Expr e = new Mul(new Add(new Num(3), new Num(4)), new Neg(new Num(2)));
System.out.println(e.accept(new Show()) + " = " + e.accept(new Eval()));
}
}
$ javac *.java && java Main
((3.0 + 4.0) * -2.0) = -14.0
The output matches ExprDemo, but it took two dispatches to get there. e.accept(v) picks the
node’s own accept, and that method calls back the one visitX that matches its type. This
“double dispatch” is how you choose code by runtime type without instanceof chains, which is all
Java offered before pattern matching. Expr is not sealed here. Instead, the visitor interface’s
fixed list of visitX methods is what makes every operation handle every node type.
Here are the same two operations as exhaustive switches over a sealed interface, in a separate
directory with the same three-file layout. The bodies are eval and show from ExprDemo.
// Expr.java
sealed interface Expr permits Num, Add, Mul, Neg {}
record Num(double value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
record Neg(Expr expr) implements Expr {}
// Ops.java
class Ops {
static double eval(Expr e) {
return switch (e) {
case Num(var v) -> v;
case Add(var l, var r) -> eval(l) + eval(r);
case Mul(var l, var r) -> eval(l) * eval(r);
case Neg(var inner) -> -eval(inner);
};
}
static String show(Expr e) {
return switch (e) {
case Num(var v) -> String.valueOf(v);
case Add(var l, var r) -> "(" + show(l) + " + " + show(r) + ")";
case Mul(var l, var r) -> "(" + show(l) + " * " + show(r) + ")";
case Neg(var inner) -> "-" + show(inner);
};
}
}
// Main.java
public class Main {
public static void main(String[] args) {
Expr e = new Mul(new Add(new Num(3), new Num(4)), new Neg(new Num(2)));
System.out.println(Ops.show(e) + " = " + Ops.eval(e));
}
}
$ javac *.java && java Main
((3.0 + 4.0) * -2.0) = -14.0
Same answer, with no ExprVisitor, no accept and no double dispatch. The records are plain data
that know nothing about the operations run on them. The sealed permits list does the job the
visitor interface did: it tells the compiler the complete set of cases.
Adding a node type to each version
Both designs claim the compiler will catch a missing case. To test that, add a Div node to the
sealed version’s Expr.java and leave Ops.java alone.
// Expr.java
sealed interface Expr permits Num, Add, Mul, Neg, Div {}
record Num(double value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
record Neg(Expr expr) implements Expr {}
record Div(Expr left, Expr right) implements Expr {}
$ javac *.java && java Main
Ops.java:4: error: the switch expression does not cover all possible input values
return switch (e) {
^
Ops.java:13: error: the switch expression does not cover all possible input values
return switch (e) {
^
2 errors
javac names both operations, eval at line 4 and show at line 13 of Ops.java, and nothing
runs. The fix is one case per switch. Main now divides by 4 so that the new node is used.
// Ops.java
class Ops {
static double eval(Expr e) {
return switch (e) {
case Num(var v) -> v;
case Add(var l, var r) -> eval(l) + eval(r);
case Mul(var l, var r) -> eval(l) * eval(r);
case Neg(var inner) -> -eval(inner);
case Div(var l, var r) -> eval(l) / eval(r);
};
}
static String show(Expr e) {
return switch (e) {
case Num(var v) -> String.valueOf(v);
case Add(var l, var r) -> "(" + show(l) + " + " + show(r) + ")";
case Mul(var l, var r) -> "(" + show(l) + " * " + show(r) + ")";
case Neg(var inner) -> "-" + show(inner);
case Div(var l, var r) -> "(" + show(l) + " / " + show(r) + ")";
};
}
}
// Main.java
public class Main {
public static void main(String[] args) {
Expr e = new Div(new Mul(new Add(new Num(3), new Num(4)), new Neg(new Num(2))), new Num(4));
System.out.println(Ops.show(e) + " = " + Ops.eval(e));
}
}
$ javac *.java && java Main
(((3.0 + 4.0) * -2.0) / 4.0) = -3.5
Now do the same in the Visitor version. A new node needs its own accept, so put it in a new file
next to the original Expr.java, Visitors.java and Main.java.
// Div.java
record Div(Expr left, Expr right) implements Expr {
public <R> R accept(ExprVisitor<R> v) { return v.visitDiv(this); }
}
$ javac *.java && java Main
Div.java:3: error: cannot find symbol
public <R> R accept(ExprVisitor<R> v) { return v.visitDiv(this); }
^
symbol: method visitDiv(Div)
location: variable v of type ExprVisitor<R>
where R is a type-variable:
R extends Object declared in method <R>accept(ExprVisitor<R>)
1 error
The first error is in the plumbing: ExprVisitor has no visitDiv. Add R visitDiv(Div d);
below visitNeg in ExprVisitor and compile again.
$ javac *.java && java Main
Visitors.java:2: error: Eval is not abstract and does not override abstract method visitDiv(Div) in ExprVisitor
class Eval implements ExprVisitor<Double> {
^
Visitors.java:9: error: Show is not abstract and does not override abstract method visitDiv(Div) in ExprVisitor
class Show implements ExprVisitor<String> {
^
2 errors
Only now does the compiler reach the operations, at the Eval and Show class declarations.
Once both have a visitDiv method, the program compiles. Both versions need the new node and one
new case per operation: a visitDiv in each visitor, or a case in each switch. The Visitor version
also needs plumbing the sealed one doesn’t have: an accept method on the node and a new method on
the interface.
The trade-off, and what sealed types change
Visitor is the classic answer to the expression problem. In an ordinary class hierarchy, a new type is cheap: you write one subclass with its own methods. A new operation is expensive, because it means a new method in every class. Visitor swaps those costs. A new operation is just one more visitor class with no edits to the nodes. A new type means touching the visitor interface and every visitor, and the compiler checks that you did.
Sealed types plus pattern-matching switch give the same balance as Visitor with far less code.
A new operation is one more method with a switch, and a new type is a compile error at every
switch that misses it. What goes away is the scaffolding: the accept methods, the visitor
interface and the double dispatch. The node types no longer have to be designed for visiting in
advance, and an operation that needs extra input takes it as an ordinary parameter, not through
visitor fields.
The safety still has one condition, the same as in the earlier MissingCase example: leave out
default. A default branch in a switch, like a visitor base class with empty visitX methods,
compiles quietly when a new type arrives. Visitor still makes sense on Java versions before 21, and
for hierarchies that are not yours to seal, such as a library that already exposes accept
(javax.lang.model’s ElementVisitor is one).
Guards and dominance
public class ClassifyDemo {
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
static String classify(Shape shape) {
return switch (shape) {
case Circle c when c.radius() > 10 -> "Large circle";
case Circle c -> "Small circle";
case Rectangle(var w, var h) when w == h -> "Square";
case Rectangle r -> "Rectangle";
};
}
public static void main(String[] args) {
Shape[] shapes = { new Circle(15), new Circle(5), new Rectangle(4, 4), new Rectangle(3, 5) };
for (Shape s : shapes) System.out.println(s + " -> " + classify(s));
}
}
$ java ClassifyDemo.java
Circle[radius=15.0] -> Large circle
Circle[radius=5.0] -> Small circle
Rectangle[width=4.0, height=4.0] -> Square
Rectangle[width=3.0, height=5.0] -> Rectangle
A when guard adds a condition to a pattern. Cases are tried top to bottom, so the guarded
Circle must come first. Put the plain case Circle c above it and the compiler rejects the
program with this case label is dominated by a preceding case label — the guarded case could
never be reached. Guarded cases do not count toward exhaustiveness, which is why the unguarded
Circle and Rectangle cases are still required.
When to use sealed types
| Situation | Use |
|---|---|
| Fixed set of alternatives, each with different data | sealed interface + records |
| Result/Either types, state machines, ASTs | sealed interface + records |
| Fixed set of alternatives with the same shape | enum |
| Open extension point or plugin API | regular interface |
| No subclassing at all | final class |
Practice
- Add a
Sub(Expr left, Expr right)node to the expression tree, wire it intoevalandshow, and evaluate(10 - 4) * 2.
Solution
public class ExprSub {
sealed interface Expr permits Num, Sub, Mul {}
record Num(double value) implements Expr {}
record Sub(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
static double eval(Expr e) {
return switch (e) {
case Num(var v) -> v;
case Sub(var l, var r) -> eval(l) - eval(r);
case Mul(var l, var r) -> eval(l) * eval(r);
};
}
static String show(Expr e) {
return switch (e) {
case Num(var v) -> String.valueOf(v);
case Sub(var l, var r) -> "(" + show(l) + " - " + show(r) + ")";
case Mul(var l, var r) -> "(" + show(l) + " * " + show(r) + ")";
};
}
public static void main(String[] args) {
Expr e = new Mul(new Sub(new Num(10), new Num(4)), new Num(2));
System.out.println(show(e) + " = " + eval(e));
}
}
$ java ExprSub.java
((10.0 - 4.0) * 2.0) = 12.0
- Add a
ship(Status, String tracking)transition to the order example that only acceptsProcessingand throws from any other state.
Solution
public class OrderShip {
sealed interface Status permits Pending, Processing, Shipped {}
record Pending() implements Status {}
record Processing(String warehouse) implements Status {}
record Shipped(String tracking) implements Status {}
static Status ship(Status s, String tracking) {
return switch (s) {
case Processing p -> new Shipped(tracking);
case Pending p -> throw new IllegalStateException("Cannot ship order in " + s);
case Shipped sh -> throw new IllegalStateException("Already shipped: " + sh.tracking());
};
}
public static void main(String[] args) {
System.out.println(ship(new Processing("WH-1"), "1Z999"));
try {
ship(new Pending(), "1Z999");
} catch (IllegalStateException e) {
System.out.println("Caught: " + e.getMessage());
}
}
}
$ java OrderShip.java
Shipped[tracking=1Z999]
Caught: Cannot ship order in Pending[]
Listing the rejected states explicitly, instead of using default, keeps the switch exhaustive:
a new state added later will force a decision here.
- Make
Motorcyclein the first examplesealed ... permits Scooterinstead ofnon-sealed, and makeScooterfinal. Confirm it still runs.
Solution
public class SealedBranch {
sealed interface Vehicle permits Car, Motorcycle {}
record Car(int doors) implements Vehicle {}
static sealed class Motorcycle implements Vehicle permits Scooter {}
static final class Scooter extends Motorcycle {}
public static void main(String[] args) {
Vehicle v = new Scooter();
String kind = switch (v) {
case Car c -> "car";
case Scooter s -> "scooter";
case Motorcycle m -> "motorcycle";
};
System.out.println(kind);
}
}
$ java SealedBranch.java
scooter
The seal now continues one level down, so the hierarchy is closed all the way to Scooter.