interface Payment can be implemented by anyone, in any file, at any time, so the compiler can never tell you that you forgot a case. Sealed classes and interfaces, final since Java 17, let you write the assumption down — and once it is written down, switch can check it for you.
This article builds that one payment model and pushes on it from every side: what permits and the three subclass modifiers mean, the six ways to break the rules and the compiler message for each, why an exhaustive switch should not have a default, generic and recursive hierarchies, how it compares with the visitor pattern, what the JVM itself enforces, and what happens when the same model is sent through Jackson 3 as JSON. Every claim comes from a real compile and run on JDK 21, 25 and 27, and each code block links to its file in the companion repository.
Versions. Tested on JDK 21.0.10 (Ubuntu OpenJDK), JDK 25.0.4.1 (Temurin, LTS) and JDK 27+35 (Temurin, GA 15 September 2026); the transcripts quoted below are from 25 unless a heading says otherwise, and the repository holds all three. No--enable-previewis needed anywhere. The JSON section uses Jackson 3.2.3 withjackson-annotations2.22 (Jackson 3 still takes its annotations from the 2.x artifact), downloaded from Maven Central and sha1-checked.openjdk.org/jepsreturned HTTP 403 to the tooling used for this article, so the language rules below come fromjavac,javaand the Java Language Specification, not from the JEP text. Amounts in the examples are whole rupees, stored aslong.
A payment is exactly one of three things
Start with the smallest correct mental model. Aninterface says “anything that can do these things”, and the list of things that can be one is open: a library you have never heard of may add a class that implements it tomorrow. A sealed interface flips that. It carries a permits list, and only the types on that list may implement it. The set of possibilities is closed, and the compiler knows the complete set.
The example used through the whole article is a payment. A record is Java’s short form for a small immutable data class, so record Card(String last4, long amount) declares a class with two fields, a constructor and accessors. The three records below are the only three permitted kinds of Payment.
public class PaymentDemo {
// A Payment is exactly one of these three things, and nothing else.
sealed interface Payment permits Card, Upi, NetBanking {}
record Card(String last4, long amount) implements Payment {}
record Upi(String vpa, long amount) implements Payment {}
record NetBanking(String bank, long amount) implements Payment {}
// No default branch: the compiler proves every permitted type is handled.
static long fee(Payment p) {
return switch (p) {
case Card(String last4, long amount) -> amount * 2 / 100; // 2 percent
case Upi(String vpa, long amount) -> 0; // free
case NetBanking(String bank, long amount) -> 15; // flat fee
};
}
static String describe(Payment p) {
return switch (p) {
case Card(String last4, long amount) when amount >= 10_000 -> "large card payment ending " + last4;
case Card(String last4, long amount) -> "card payment ending " + last4;
case Upi(String vpa, long amount) -> "UPI payment from " + vpa;
case NetBanking(String bank, long amount) -> "net banking via " + bank;
};
}
public static void main(String[] args) {
Payment[] all = {
new Card("4242", 500), new Card("1111", 25_000),
new Upi("ankur@bank", 500), new NetBanking("HDFC", 500)
};
for (Payment p : all) {
System.out.println(p + " -> fee " + fee(p) + " | " + describe(p));
}
}
}
Source: PaymentDemo.java. Look at fee. It is a switch over a Payment with one case per permitted type and no default, and it compiles. That is the whole point: because the compiler knows Payment can only be a Card, a Upi or a NetBanking, three cases are provably enough. The case Card(String last4, long amount) form is a record pattern, which takes the record apart and names its fields; the previous article on this site covers those in detail.
$ javac src/PaymentDemo.java && java PaymentDemo (25.0.4.1+1-LTS)
Card[last4=4242, amount=500] -> fee 10 | card payment ending 4242
Card[last4=1111, amount=25000] -> fee 500 | large card payment ending 1111
Upi[vpa=ankur@bank, amount=500] -> fee 0 | UPI payment from ankur@bank
NetBanking[bank=HDFC, amount=500] -> fee 15 | net banking via HDFC
Output: 01-payment-demo.txt, which also holds the JDK 21 and 27 runs (identical). A card payment of 500 costs 10, UPI is free, net banking is a flat 15, and the guarded case picks out the large card payment.
The one idea to carry forward.sealeddoes not add a feature to your classes. It adds a fact to your types — “this is the complete list” — and lets the compiler use that fact inswitch. The rest of the article is about keeping that fact true.
Going deeper: which Java version gives you which half
Sealed types and the exhaustive switch over them arrived in different releases, and the compiler tells you which one you are missing. Compiling a file that only declares a sealed class with --release 16 is rejected with a message naming the fix, the same file with --release 17 compiles, and the payment example with --release 17 gets past the sealed declaration and stops at the record patterns in the switch, which need 21:
$ javac --release 16 src/ImplicitPermits.java (25.0.4.1+1-LTS)
src/ImplicitPermits.java:4: error: sealed classes are not supported in -source 16
public sealed class ImplicitPermits {
^
(use -source 17 or higher to enable sealed classes)
1 error
exit=1
$ javac --release 17 src/ImplicitPermits.java (25.0.4.1+1-LTS)
exit=0
$ javac --release 17 src/PaymentDemo.java (25.0.4.1+1-LTS)
src/PaymentDemo.java:11: error: patterns in switch statements are not supported in -source 17
case Card(String last4, long amount) -> amount * 2 / 100; // 2 percent
^
(use -source 21 or higher to enable patterns in switch statements)
src/PaymentDemo.java:11: error: deconstruction patterns are not supported in -source 17
Source: ImplicitPermits.java, output in 09-release-levels.txt. In practice: sealed declarations need a Java 17 language level, and the pattern-matching switch that makes them pay off needs 21. A project still compiling with --release 17 can declare sealed hierarchies but has to consume them with instanceof chains or a visitor.
Going deeper on this section
- Companion repo: sealed README (how to regenerate every transcript)
- Official reference: JLS 8.1.6, Permitted Direct Subclasses and JLS 9.1.4, Permitted Direct Subclasses and Subinterfaces
- Related on this site: Pattern Matching in Java: switch, Record Patterns and Primitive Patterns (JDK 21 to 27), the record patterns used in
fee
What permits and the three modifiers actually say
A sealed type makes three demands of the types it names. Each permitted subtype must directly extend or implement the sealed type, must live where the compiler can see it (the same module, or the same package when there are no modules), and must say what it does about further extension by carrying exactly one of three modifiers:final (no more subtypes), sealed (more subtypes, but a closed list again) or non-sealed (open the hierarchy back up from here). Records are implicitly final, so they need no modifier (the run below reports them as final).
The example below uses all three, plus a record and a plain final class, and then asks the running program to describe itself.
import java.lang.reflect.Modifier;
import java.util.Arrays;
public class Reflect {
sealed interface Shape permits Circle, Poly, Custom {}
record Circle(double r) implements Shape {} // records are implicitly final
sealed interface Poly extends Shape permits Tri, Quad {} // sealed again: the hierarchy can be deeper
record Tri(double b, double h) implements Poly {}
static final class Quad implements Poly {} // an explicit final class
non-sealed interface Custom extends Shape {} // re-opens the hierarchy below this point
static class Hexagon implements Custom {} // legal, and not in any permits clause
static void show(Class<?> c) {
String kind = c.isSealed() ? "sealed" : Modifier.isFinal(c.getModifiers()) ? "final" : "open (non-sealed or plain)";
System.out.println(c.getSimpleName() + ": " + kind
+ (c.isSealed() ? ", permits " + Arrays.stream(c.getPermittedSubclasses()).map(Class::getSimpleName).toList() : ""));
}
// Custom is covered by one case; Hexagon and any future subtype of Custom are covered by it too.
static String name(Shape s) {
return switch (s) {
case Circle c -> "circle";
case Tri t -> "triangle";
case Quad q -> "quad";
case Custom c -> "custom:" + c.getClass().getSimpleName();
};
}
public static void main(String[] args) {
for (Class<?> c : new Class<?>[]{Shape.class, Circle.class, Poly.class, Tri.class, Quad.class, Custom.class, Hexagon.class}) show(c);
System.out.println(name(new Circle(1)) + " " + name(new Tri(1, 2)) + " " + name(new Quad()) + " " + name(new Hexagon()));
}
}
Source: Reflect.java. Class.isSealed() and Class.getPermittedSubclasses() are the run-time view of the same information. Notice that name has a single case Custom c for the non-sealed branch, and that Hexagon, which is not in any permits clause, is handled by it.
$ javac src/Reflect.java && java Reflect (25.0.4.1+1-LTS)
Shape: sealed, permits [Circle, Poly, Custom]
Circle: final
Poly: sealed, permits [Tri, Quad]
Tri: final
Quad: final
Custom: open (non-sealed or plain)
Hexagon: open (non-sealed or plain)
circle triangle quad custom:Hexagon
Output: 02-permits-and-modifiers.txt. Shape and Poly are sealed with their lists, Circle, Tri and Quad report final (the record is final without saying so), and Custom and Hexagon are open. The last line shows the exhaustive switch covering all of them, including a subtype the sealed list never mentioned.
non-sealed: below Custom the compiler can no longer list the subtypes, so a switch covers the whole branch with one case Custom and cannot say anything about what is underneath.
Choose the modifier on purpose.finalis the default choice: the family stays fully known.sealedis for a branch with real substructure.non-sealedis a deliberate hole; use it when you genuinely want plug-in subtypes under one branch, and expect theswitchto treat that whole branch as one case.
Going deeper: the implicit permits list and the class-file attribute
If every subtype is in the same file as the sealed type, the permits clause can be left out and javac fills it in from the file:
import java.util.Arrays;
// If the subclasses live in the same file, the permits clause can be left out: javac infers it.
public sealed class ImplicitPermits {
static final class A extends ImplicitPermits {}
static final class B extends ImplicitPermits {}
public static void main(String[] args) {
System.out.println(Arrays.stream(ImplicitPermits.class.getPermittedSubclasses()).map(Class::getName).toList());
}
}
Source: ImplicitPermits.java. The reflection call reports the two nested classes, in declaration order:
$ javac src/ImplicitPermits.java && java ImplicitPermits (25.0.4.1+1-LTS)
[ImplicitPermits$A, ImplicitPermits$B]
The list does not disappear after compilation. It is written into the class file as a PermittedSubclasses attribute, which is what getPermittedSubclasses() reads and what the JVM checks when a class loads (a later section relies on that):
$ javap -v -cp . 'PaymentDemo$Payment' | grep -A3 PermittedSubclasses (25.0.4.1+1-LTS)
PermittedSubclasses:
PaymentDemo$Card
PaymentDemo$Upi
PaymentDemo$NetBanking
Output: 02-permits-and-modifiers.txt. I recommend writing permits out anyway when the subtypes are in separate files or when the list is part of a public API: the explicit list is documentation, and it makes adding a subtype an edit to the sealed type itself, which shows up in review.
Going deeper on this section
- Companion repo: src/ (every runnable example in this article)
- Official reference:
Class.isSealed()andClass.getPermittedSubclasses() - Related on this site: Jackson with Java Records, Optionals, and Sealed Classes (Java 21+)
The compiler enforces the rules: six mistakes and their messages
A rule you have not seen fail is a rule you will misremember, so here are the mistakes a beginner is most likely to make, each in its own file, each compiled on all three JDKs. The first three are the ones you will actually meet; the other three are in the accordion. The first is adding a class that is not on the list. The message says so in as many words:public class NotPermitted {
sealed interface Payment permits Card, Upi {}
record Card(long amount) implements Payment {}
record Upi(long amount) implements Payment {}
record Cheque(long amount) implements Payment {} // not in the permits clause
}
$ javac broken/NotPermitted.java (25.0.4.1+1-LTS)
broken/NotPermitted.java:5: error: class is not allowed to extend sealed class: Payment (as it is not listed in its 'permits' clause)
record Cheque(long amount) implements Payment {} // not in the permits clause
^
1 error
Source: NotPermitted.java, output in 03-compile-errors.txt. Adding Cheque means editing the sealed type, which is exactly what “sealed” is for.
The second is forgetting the modifier. A permitted subtype that is a plain class must say what happens next:
public class MissingModifier {
sealed interface Payment permits Card {}
class Card implements Payment {} // must say final, sealed or non-sealed
}
$ javac broken/MissingModifier.java (25.0.4.1+1-LTS)
broken/MissingModifier.java:3: error: sealed, non-sealed or final modifiers expected
class Card implements Payment {} // must say final, sealed or non-sealed
^
1 error
Source: MissingModifier.java. Records never hit this, which is one reason sealed hierarchies of records read so cleanly.
The third is putting the subtypes in another package. In a program without modules, a sealed type and its permitted subtypes have to be in the same package:
package a;
public sealed interface Base permits b.Impl {}
$ javac broken/xpkg/a/Base.java broken/xpkg/b/Impl.java (25.0.4.1+1-LTS)
broken/xpkg/a/Base.java:2: error: class Base in unnamed module cannot extend a sealed class in a different package
public sealed interface Base permits b.Impl {}
^
1 error
Source: Base.java (the subtype is Impl.java). A named module lifts this limit, as a later section shows.
Going deeper: the other three mistakes, and the wording that changed on 27
A sealed type with no permitted subtypes at all (NoSubclass.java), a non-sealed modifier under a supertype that is not sealed (NonSealedWrongPlace.java), and an anonymous class of a sealed type (AnonymousSealed.java) are each rejected with their own message, quoted from 03-compile-errors.txt:
$ javac broken/NoSubclass.java (25.0.4.1+1-LTS)
broken/NoSubclass.java:2: error: sealed class must have subclasses
sealed interface Payment {} // no permits clause and no subtype in this file
^
1 error
$ javac broken/NonSealedWrongPlace.java (25.0.4.1+1-LTS)
broken/NonSealedWrongPlace.java:3: error: non-sealed modifier not allowed here
non-sealed class Oops implements Plain {} // non-sealed only makes sense under a sealed supertype
^
(class NonSealedWrongPlace.Oops does not have any sealed supertypes)
1 error
$ javac broken/AnonymousSealed.java (25.0.4.1+1-LTS)
broken/AnonymousSealed.java:5: error: anonymous classes must not extend sealed classes
return new Payment() {}; // an anonymous class is a subtype nobody listed
^
1 error
The anonymous-class rule is the one to remember: new Payment() {} would create a subtype nobody listed, so it is refused. Across these files the JDK 21, 25 and 27 compile errors are word for word identical; the only compile error whose wording differs between JDKs is the exhaustiveness error in the next section, where 27 adds a line.
Going deeper on this section
- Companion repo: broken/ (one rule per file, each fails to compile on purpose)
- Official reference: JLS 8.1.1.2, sealed, non-sealed and final classes
Exhaustive switch: add a type and every switch tells you
This is where the closed family pays for itself. Suppose the shop starts accepting wallet payments. You addWallet to the permits list and write the record, and you forget the one fee method that handles payments. Without sealed types that is a bug that reaches production; with them, it is a compile error at the exact switch that needs attention.
public class WalletNotHandled {
sealed interface Payment permits Card, Upi, NetBanking, Wallet {} // Wallet added
record Card(long amount) implements Payment {}
record Upi(long amount) implements Payment {}
record NetBanking(long amount) implements Payment {}
record Wallet(long amount) implements Payment {}
// Same switch as before, with no default and no case for Wallet.
static long fee(Payment p) {
return switch (p) {
case Card c -> c.amount() * 2 / 100;
case Upi u -> 0;
case NetBanking n -> 15;
};
}
}
Source: WalletNotHandled.java.
$ javac broken/WalletNotHandled.java (25.0.4.1+1-LTS)
broken/WalletNotHandled.java:10: error: the switch expression does not cover all possible input values
return switch (p) {
^
1 error
On JDK 27 the same file gets a more helpful message. It names the missing type:
$ javac broken/WalletNotHandled.java (27+35)
broken/WalletNotHandled.java:10: error: the switch expression does not cover all possible input values
return switch (p) {
^
missing patterns:
Wallet _
1 error
Output: 03-compile-errors.txt. JDK 21 and 25 print the first line only; 27 adds missing patterns: and the type. The rule is the same on all three, so on an older JDK you find the missing case by looking at what you just added.
Now the trap. Suppose that fee had been written with a default branch, as a catch-all for “everything else”. The compiler is satisfied by a default, so it stops checking the cases, and the new Wallet quietly falls into it:
public class WithDefault {
sealed interface Payment permits Card, Upi, NetBanking, Wallet {} // Wallet was added later
record Card(long amount) implements Payment {}
record Upi(long amount) implements Payment {}
record NetBanking(long amount) implements Payment {}
record Wallet(long amount) implements Payment {}
// Written back when there were only three kinds. The default keeps it compiling, and keeps it wrong.
static long feeWithDefault(Payment p) {
return switch (p) {
case Card c -> c.amount() * 2 / 100;
case Upi u -> 0;
default -> 15;
};
}
public static void main(String[] args) {
System.out.println("fee for Wallet(1000) = " + feeWithDefault(new Wallet(1000)) + " (the rule for Wallet is 1 percent, so 10; it compiled without any complaint)");
}
}
Source: WithDefault.java.
$ javac src/WithDefault.java && java WithDefault (25.0.4.1+1-LTS)
fee for Wallet(1000) = 15 (the rule for Wallet is 1 percent, so 10; it compiled without any complaint)
Output: 04-default-trap.txt. It compiled, it ran, and it charged the net-banking flat fee to a wallet payment. No error, no warning, and the identical result on JDK 21, 25 and 27.
Do not put adefaulton a switch over a sealed type. Adefaulttells the compiler “stop checking”, and the check is the reason you sealed the type. Keepdefaultfor switches over open types such asObjectorString, where no complete list exists. If several cases really do share behaviour, write each case out and have them call one helper; the extra lines are the price of keeping the check.
Going deeper: what “exhaustive” means, and what it does not cover
A switch is exhaustive when the language specification can show that every possible value reaches some case. For a sealed type that means one case for each permitted subtype (a case for a supertype covers all of its subtypes, so case Payment p would also count, and would also make the check pointless; I did not compile that variant). The rules, including how record patterns nest, are in JLS 14.11.1.1.
The check happens when the switch is compiled, against the sealed type as it looked at that moment. If the sealed type later gains a subtype and the switch is not recompiled, the old class file has no case for it. The previous article shows that failure end to end (it surfaces as a MatchException at run time) in 05-separate-compilation.txt. The practical rule is the boring one: rebuild everything that switches over a type when the type changes.
Going deeper on this section
- Companion repo: 04-default-trap.txt (the silent wrong fee, on 21, 25 and 27)
- Official reference: JLS 14.11.1.1, Exhaustive Switch Blocks
- Related on this site: Pattern Matching in Java, including the separate-compilation failure and dominance rules
Beyond a flat list: generic and recursive hierarchies
Real domains are rarely one level deep or free of type parameters, and sealed types handle both. A generic sealed interface is the standard way to model “this operation returns a value or an error” without exceptions:Result<T> is either an Ok holding a T or an Err holding a message.
import java.util.function.Function;
public class ResultDemo {
// A generic sealed type: a Result<T> is either an Ok holding a T or an Err holding a message.
sealed interface Result<T> permits Ok, Err {}
record Ok<T>(T value) implements Result<T> {}
record Err<T>(String message) implements Result<T> {}
static <T, R> Result<R> map(Result<T> r, Function<T, R> f) {
return switch (r) {
case Ok<T>(T value) -> new Ok<>(f.apply(value));
case Err<T>(String message) -> new Err<>(message);
};
}
static Result<Integer> parse(String s) {
try { return new Ok<>(Integer.parseInt(s)); }
catch (NumberFormatException e) { return new Err<>("not a number: " + s); }
}
public static void main(String[] args) {
for (String s : new String[]{"21", "abc"}) {
Result<Integer> doubled = map(parse(s), n -> n * 2);
String text = switch (doubled) {
case Ok<Integer>(Integer v) -> "ok " + v;
case Err<Integer>(String m) -> "error: " + m;
};
System.out.println(s + " -> " + text);
}
}
}
Source: ResultDemo.java. Both switches are exhaustive without a default, even with the type parameter in play: the compiler checks Ok<T> and Err<T> and is satisfied.
$ javac src/ResultDemo.java && java ResultDemo (25.0.4.1+1-LTS)
21 -> ok 42
abc -> error: not a number: abc
Output: 05-generic-and-recursive.txt. A valid number goes through map and comes out doubled; a bad one skips map and arrives as the original error.
switch at the end has to handle both shapes because there is no third one.
Going deeper: a recursive hierarchy and nested record patterns
Sealed types shine on trees, where each node is one of a few kinds and some kinds contain more nodes. A small expression tree makes the point, and the simplify method shows a record pattern looking two levels deep in a single case:
public class ExprDemo {
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 inner) implements Expr {}
static int eval(Expr e) {
return switch (e) {
case Num(int v) -> v;
case Add(Expr l, Expr r) -> eval(l) + eval(r);
case Mul(Expr l, Expr r) -> eval(l) * eval(r);
case Neg(Expr i) -> -eval(i);
};
}
// Nested record patterns look two levels deep in one case.
static Expr simplify(Expr e) {
return switch (e) {
case Add(Num(int a), Num(int b)) -> new Num(a + b);
case Mul(Num(int a), Expr r) when a == 1 -> simplify(r);
case Neg(Neg(Expr inner)) -> simplify(inner);
case Add(Expr l, Expr r) -> new Add(simplify(l), simplify(r));
case Mul(Expr l, Expr r) -> new Mul(simplify(l), simplify(r));
case Neg(Expr i) -> new Neg(simplify(i));
case Num n -> n;
};
}
public static void main(String[] args) {
Expr e = new Add(new Mul(new Num(1), new Num(7)), new Neg(new Neg(new Num(3))));
System.out.println(e);
System.out.println("eval = " + eval(e));
Expr s = simplify(e);
System.out.println("simplify = " + s + " (eval " + eval(s) + ")");
}
}
Source: ExprDemo.java. Case order matters here: the specific Add(Num, Num) comes before the general Add(Expr, Expr), because a case that an earlier one has already made unreachable is a compile error (the previous article shows that error for type patterns; I did not compile the reversed order here). The result:
$ javac src/ExprDemo.java && java ExprDemo (25.0.4.1+1-LTS)
Add[left=Mul[left=Num[value=1], right=Num[value=7]], right=Neg[inner=Neg[inner=Num[value=3]]]]
eval = 10
simplify = Add[left=Num[value=7], right=Num[value=3]] (eval 10)
Output: 05-generic-and-recursive.txt. 1 * 7 + -(-3) evaluates to 10, and simplifying it first (multiplying by one drops out and the double negation cancels) gives a smaller tree that still evaluates to 10. The two remaining numbers are not folded into one: the fold case runs before the children are simplified, so it never sees Num and Num. A second pass, or simplifying the children first, would fold them; I left it as is because it shows how case order and recursion interact. This is the same shape a compiler, a rules engine or a JSON tree uses; the Interpreter pattern post on this site builds the classic object-oriented version of it.
Going deeper on this section
- Companion repo: 05-generic-and-recursive.txt (both programs, on 21, 25 and 27)
- Related on this site: Interpreter Design Pattern in Java: Building an Expression Evaluator
Sealed types against the visitor pattern
Before sealed types, the standard way to get a compiler-checked “one method per kind” was the visitor pattern. It is worth seeing the same fee calculation written that way, because it shows exactly what sealed types replaced.public class VisitorVersion {
// The pre-sealed way: a visitor interface, an accept method on every type, and one implementation per operation.
interface Payment { <R> R accept(Visitor<R> v); }
interface Visitor<R> { R visitCard(Card c); R visitUpi(Upi u); R visitNetBanking(NetBanking n); }
record Card(String last4, long amount) implements Payment { public <R> R accept(Visitor<R> v) { return v.visitCard(this); } }
record Upi(String vpa, long amount) implements Payment { public <R> R accept(Visitor<R> v) { return v.visitUpi(this); } }
record NetBanking(String bank, long amount) implements Payment { public <R> R accept(Visitor<R> v) { return v.visitNetBanking(this); } }
static class Fee implements Visitor<Long> {
public Long visitCard(Card c) { return c.amount() * 2 / 100; }
public Long visitUpi(Upi u) { return 0L; }
public Long visitNetBanking(NetBanking n) { return 15L; }
}
public static void main(String[] args) {
Payment[] all = { new Card("4242", 500), new Upi("ankur@bank", 500), new NetBanking("HDFC", 500) };
for (Payment p : all) System.out.println(p + " -> fee " + p.accept(new Fee()));
}
}
Source: VisitorVersion.java. Every payment type carries an accept method that calls back into the right visit... method, and every operation is a class implementing Visitor.
$ javac src/VisitorVersion.java && java VisitorVersion (25.0.4.1+1-LTS)
Card[last4=4242, amount=500] -> fee 10
Upi[vpa=ankur@bank, amount=500] -> fee 0
NetBanking[bank=HDFC, amount=500] -> fee 15
Output: 06-visitor.txt. Same answers as the sealed version, with a visitor interface, an accept on every type and a separate class for the operation.
The visitor does give you the same safety when you add a type. Add visitWallet to the interface, and every existing visitor fails to compile:
$ javac broken/VisitorAdded.java (25.0.4.1+1-LTS)
broken/VisitorAdded.java:9: error: Fee is not abstract and does not override abstract method visitWallet(Wallet) in Visitor
static class Fee implements Visitor<Long> {
^
broken/VisitorAdded.java:13: error: Label is not abstract and does not override abstract method visitWallet(Wallet) in Visitor
static class Label implements Visitor<String> {
^
2 errors
Source: VisitorAdded.java. Two visitors, two errors. So what is different is not safety but cost: the visitor makes you edit an interface and every implementation class, while the sealed version makes you edit only the switch statements, which are ordinary methods you already have.
switch (a Java version below 21) or when operations must be added by code you do not control.
What about an enum? An enum is a closed family too, but every constant is the same shape. The moment the variants carry different data (a card has a last-four, a UPI payment has an address), an enum forces you to stuff every field into one class. A sealed hierarchy of records gives each variant only the fields it has. That is a modelling argument, not something I measured.
Going deeper: where the visitor still wins, and what an abstract class costs you
A third option is an abstract Payment class with an abstract fee() method that each subclass overrides. It also works, and it is the right answer when the behaviour belongs to the type. It stops working when the operation does not belong there: a JSON writer, a receipt printer and a fraud check are three unrelated operations that would all have to be methods on Payment. A switch keeps each operation in one place, next to the code that needs it, and leaves Payment as plain data.
The visitor pattern is described in full on this site, including double dispatch, which is the trick that makes accept work: Visitor Design Pattern in Java.
Going deeper on this section
- Companion repo: VisitorVersion.java and VisitorAdded.java
- Related on this site: Visitor Design Pattern in Java: Complete Guide with Examples
The rules do not stop at the compiler: the JVM and modules
Everything so far was checked when the code was compiled. It would be a weak guarantee if anyone could bypass it by handing the JVM a hand-made class file, so the JVM checks thePermittedSubclasses list again whenever a class is loaded. This section proves it by building the situation on purpose. Payment is compiled in two versions: the real one permits only Card, and a second one, used only to compile a Hack class, permits Hack too.
public sealed interface Payment permits Card {}
public sealed interface Payment permits Card, Hack {}
Sources: v1/Payment.java (the real one) and evil/Payment.java (the one Hack was compiled against). The run then puts the real Payment and Card on the class path together with the Hack.class from the second compile, and tries to load both:
$ java -cp v1+Hack.class Main (Hack was compiled against a Payment that permitted it) (25.0.4.1+1-LTS)
loading Card: Card
loading Hack...
Exception in thread "main" java.lang.IncompatibleClassChangeError: Failed listed permitted subclass check: class Hack is not a permitted subclass of Payment
Output: 07-runtime-enforcement.txt, with Main.java as the driver. Card loads, Hack does not: the JVM throws IncompatibleClassChangeError and names the failed check. A class that looks permitted to the compiler that built it is still rejected by the JVM that loads it, because the JVM consults the real sealed type.
The error text differs by JDK. JDK 21 saysThe other place the rules bend is modules. In an ordinary program a sealed type and its subtypes must share a package. In a named module (a project with aclass Hack cannot implement sealed interface Payment; JDK 25 and 27 sayFailed listed permitted subclass check: class Hack is not a permitted subclass of Payment. The exception type is the same on all three. The full transcript is in the repository; match on the exception class, not the message.
module-info.java), they may be in different packages of that module, so an API package can seal a type whose implementations live in an internal package:
package pay.api;
public sealed interface Payment permits pay.impl.Card, pay.impl.Upi {}
$ javac -d out $(find multi/demo.pay -name '*.java') && java -p out -m demo.pay/pay.app.Main (25.0.4.1+1-LTS)
card in module demo.pay
Source: Payment.java (with module-info.java and the rest of the demo.pay module), output in 08-modules.txt. The same code that failed in the unnamed module compiles and runs, and the switch is still exhaustive across the two packages.
Going deeper: the JDK 21 run of the same experiment, and what I did not test
The 21 transcript, from the same file:
$ java -cp v1+Hack.class Main (Hack was compiled against a Payment that permitted it) (21.0.10+7-Ubuntu-124.04)
loading Card: Card
loading Hack...
Exception in thread "main" java.lang.IncompatibleClassChangeError: class Hack cannot implement sealed interface Payment
What I did not test: what a build tool such as Maven or Gradle does with a sealed hierarchy split across artifacts (the split-package rules are the module system’s, and I have not run a multi-jar case), and whether any bytecode-rewriting library in your stack (a proxy generator, an instrumenting agent) tolerates a sealed superclass. Both are reasoned from how the rules work, not observed here.
Going deeper on this section
- Companion repo: sep/ and multi/ (the run-time check and the module case)
- Official reference: JVMS 4.7.31, the PermittedSubclasses attribute
- Related on this site: Pattern Matching in Java, which shows the
MatchExceptioncase that can still happen after an exhaustive compile
Sealed types and JSON: what Jackson 3 does with them
A payment usually arrives as JSON, and JSON has no types, so a field such as"type": "card" has to tell the parser which class to build. Jackson’s standard tool for that is @JsonTypeInfo (which field holds the type name) with @JsonSubTypes (which name means which class). The sealed interface below is the same Payment, with those two annotations added.
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import tools.jackson.databind.json.JsonMapper;
public class JsonPayment {
// Two annotations tell Jackson which JSON field names the type, and which class each name means.
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = Card.class, name = "card"),
@JsonSubTypes.Type(value = Upi.class, name = "upi"),
@JsonSubTypes.Type(value = NetBanking.class, name = "netbanking")})
sealed interface Payment permits Card, Upi, NetBanking {}
record Card(String last4, long amount) implements Payment {}
record Upi(String vpa, long amount) implements Payment {}
record NetBanking(String bank, long amount) implements Payment {}
static long fee(Payment p) {
return switch (p) {
case Card(String last4, long amount) -> amount * 2 / 100;
case Upi(String vpa, long amount) -> 0;
case NetBanking(String bank, long amount) -> 15;
};
}
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
Payment[] all = { new Card("4242", 500), new Upi("ankur@bank", 500), new NetBanking("HDFC", 500) };
for (Payment p : all) {
String json = mapper.writeValueAsString(p);
Payment back = mapper.readValue(json, Payment.class);
System.out.println(json + " -> " + back + " equal=" + back.equals(p) + " fee=" + fee(back));
}
try {
mapper.readValue("{\"type\":\"cheque\",\"amount\":500}", Payment.class);
} catch (Exception e) {
System.out.println("unknown type: " + e.getClass().getSimpleName() + ": " + e.getMessage().lines().findFirst().orElse(""));
}
}
}
Source: JsonPayment.java. In Jackson 3 the mapper lives in tools.jackson.databind while the annotations stay in com.fasterxml.jackson.annotation, and its exceptions are unchecked, so none of the calls need a throws.
$ javac -cp jackson/lib/* jackson/JsonPayment.java && java JsonPayment (25.0.4.1+1-LTS)
{"type":"card","last4":"4242","amount":500} -> Card[last4=4242, amount=500] equal=true fee=10
{"type":"upi","vpa":"ankur@bank","amount":500} -> Upi[vpa=ankur@bank, amount=500] equal=true fee=0
{"type":"netbanking","bank":"HDFC","amount":500} -> NetBanking[bank=HDFC, amount=500] equal=true fee=15
unknown type: InvalidTypeIdException: Could not resolve type id 'cheque' as a subtype of `JsonPayment$Payment`: known type ids = [card, netbanking, upi]
Output: 10-jackson.txt. Each payment writes out with its type field, reads back as the same record (equal=true), and goes into the exhaustive fee switch. An unknown type is rejected with InvalidTypeIdException and a list of the known ids.
Now the part that matters for a sealed type. The compiler guarantees that the Java side is a closed family. It does not know that @JsonSubTypes is a second copy of the same list, and nothing keeps the two in sync. Add a Wallet to permits and forget the annotation, and everything compiles:
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import tools.jackson.databind.json.JsonMapper;
public class JsonForgotten {
// Wallet is a permitted subtype, and the compiler is happy, but nobody told Jackson about it.
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = Card.class, name = "card"),
@JsonSubTypes.Type(value = Upi.class, name = "upi")})
sealed interface Payment permits Card, Upi, Wallet {}
record Card(long amount) implements Payment {}
record Upi(long amount) implements Payment {}
record Wallet(long amount) implements Payment {}
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
System.out.println("write : " + mapper.writeValueAsString(new Wallet(500)));
try {
mapper.readValue("{\"type\":\"wallet\",\"amount\":500}", Payment.class);
} catch (Exception e) {
System.out.println("read : " + e.getClass().getSimpleName() + ": " + e.getMessage().lines().findFirst().orElse(""));
}
}
}
Source: JsonForgotten.java.
$ javac -cp jackson/lib/* jackson/JsonForgotten.java && java JsonForgotten (25.0.4.1+1-LTS)
write : {"type":"JsonForgotten$Wallet","amount":500}
read : InvalidTypeIdException: Could not resolve type id 'wallet' as a subtype of `JsonForgotten$Payment`: known type ids = [card, upi]
The write side even “works”, and writes the Java class name as the type id, while the read side fails for the name a client would actually send. This is a run-time failure in exactly the place where sealed types promised none.
Wallet is on the Java side and missing on the Jackson side, and the compiler sees only one of them. The green box is the way out, and it comes from a test rather than the documentation: does Jackson 3 read the sealed type’s own permits list when @JsonSubTypes is absent?
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import tools.jackson.databind.json.JsonMapper;
public class JsonNoSubTypes {
// Only @JsonTypeInfo, no @JsonSubTypes. Does Jackson find the permitted subclasses by itself?
@JsonTypeInfo(use = JsonTypeInfo.Id.SIMPLE_NAME, property = "type")
sealed interface Payment permits Card, Upi {}
record Card(long amount) implements Payment {}
record Upi(long amount) implements Payment {}
// Control: the same shape without the word sealed.
@JsonTypeInfo(use = JsonTypeInfo.Id.SIMPLE_NAME, property = "type")
interface OpenPayment {}
record OpenCard(long amount) implements OpenPayment {}
static void tryRead(JsonMapper m, String label, String json, Class<?> type) {
try {
System.out.println(label + ": " + m.readValue(json, type));
} catch (Exception e) {
System.out.println(label + ": " + e.getClass().getSimpleName() + ": " + e.getMessage().lines().findFirst().orElse(""));
}
}
public static void main(String[] args) {
JsonMapper m = JsonMapper.builder().build();
System.out.println("write : " + m.writeValueAsString(new Card(500)));
tryRead(m, "sealed, Card ", "{\"type\":\"Card\",\"amount\":500}", Payment.class);
tryRead(m, "sealed, Upi ", "{\"type\":\"Upi\",\"amount\":500}", Payment.class);
tryRead(m, "sealed, Cheque", "{\"type\":\"Cheque\",\"amount\":500}", Payment.class);
tryRead(m, "plain, OpenCard", "{\"type\":\"OpenCard\",\"amount\":500}", OpenPayment.class);
}
}
Source: JsonNoSubTypes.java. It reads with only @JsonTypeInfo(use = SIMPLE_NAME), and includes a control: the same shape on an interface that is not sealed.
$ javac -cp jackson/lib/* jackson/JsonNoSubTypes.java && java JsonNoSubTypes (25.0.4.1+1-LTS)
write : {"type":"Card","amount":500}
sealed, Card : Card[amount=500]
sealed, Upi : Upi[amount=500]
sealed, Cheque: InvalidTypeIdException: Could not resolve type id 'Cheque' as a subtype of `JsonNoSubTypes$Payment`: known type ids = [Card, Upi]
plain, OpenCard: InvalidTypeIdException: Could not resolve type id 'OpenCard' as a subtype of `JsonNoSubTypes$OpenPayment`: known type ids = []
On 3.2.3 the sealed type resolves Card and Upi with no @JsonSubTypes at all, refuses Cheque (not permitted) and lists the known ids as [Card, Upi]; the control finds none (known type ids = []). So Jackson 3.2.3 does use the permitted-subclass list of a sealed type. That removes the second list, at the price of type ids that are the simple class names (Card, not card); if you need your own names, you are back to @JsonSubTypes and to keeping it in sync by hand, ideally with a unit test that round-trips every permitted subtype (getPermittedSubclasses() gives you the list to loop over).
Going deeper: what I did not test, and where the rest of Jackson is covered
This module ran Jackson 3.2.3 only. I did not run Jackson 2, so I cannot say whether the automatic use of permits exists there. I also did not test @JsonTypeInfo(use = NAME) without @JsonSubTypes on a sealed type (the forgotten-subtype run above shows what a partial list does: the unlisted subtype writes out under its class name and cannot be read back by a lower-case client name), custom type-id resolvers, or Kotlin. The three jars are fetched and checked by fetch.sh and are not committed.
The rest of Jackson’s polymorphism story, including the security reasons to prefer an explicit list over default typing, is covered in Jackson Polymorphic Deserialisation: Handling Inheritance Hierarchies with @JsonTypeInfo, Jackson Security Best Practices and the Jackson 2 to 3 migration guide.
Going deeper on this section
- Companion repo: jackson/ (three programs and the jar fetch script)
- Related on this site: Jackson Polymorphic Deserialisation, Jackson with Java Records, Optionals, and Sealed Classes and Jackson 2 to Jackson 3 Migration Guide
- Official reference: jackson-annotations on GitHub
Should you seal your types?
Yes for a closed domain you own; no for an extension point. If the set of variants is a fact about your business (payment kinds, order states, tokens in a grammar, the result of an operation) and the same team maintains the code that consumes it, seal it and write your switches withoutdefault. If outside code is meant to add implementations (a plug-in interface, a strategy that users provide), do not seal it, or seal only the part you own and mark the extension branchnon-sealed. Two more cautions, both reasoned rather than tested: a sealed type is part of your API, so adding a permitted subtype is a breaking change for every consumer with an exhaustiveswitch; and if a framework generates subclasses at run time (proxies, mocks), check that it copes with a sealed or final type before you seal it. And whatever you serialise, keep the JSON type list and thepermitslist in step with a test.
Further reading
- Companion repository for this article: javademos, sealed module
- Official reference: JEP 409: Sealed Classes (final in Java 17)
- Official reference: JLS 8.1.6, Permitted Direct Subclasses and JLS 14.11.1.1, Exhaustive Switch Blocks
- Related on this site: Pattern Matching in Java: switch, Record Patterns and Primitive Patterns (JDK 21 to 27)
- Related on this site: Jackson Polymorphic Deserialisation: Handling Inheritance Hierarchies with @JsonTypeInfo
- Related on this site: Visitor Design Pattern in Java: Complete Guide with Examples
- Related on this site: Java 27 Is Out: Every JEP, Plus the Java 26 Changes You Skipped
No Comments yet!