Sealed classes restrict type hierarchies to a fixed set of permitted subtypes, enforced at compile time
The permits clause lists allowed direct subtypes; subtypes must be final, sealed, or non-sealed
Combined with records, sealed types model algebraic data types with auto-generated methods and immutable state
Pattern matching over sealed types enables exhaustive switches without a default arm — compile-time safety net
JIT devirtualizes method dispatch on sealed types with final subtypes, yielding 10-15% throughput gains on hot paths
CGLIB proxies fail on sealed classes — switch to interface-based proxying or avoid AOP on sealed hierarchy
Plain-English First
Imagine a theme park with a VIP lounge. The manager posts a guest list at the door — only the names on that list get in, no exceptions. Sealed classes work exactly like that guest list for your type hierarchy: you declare upfront which classes are allowed to extend or implement yours, and the compiler enforces it forever. Nobody sneaks in from another package at 2am. That's the whole idea.
Every large Java codebase eventually grows a tangled web of inheritance. You design a clean hierarchy, publish it, and six months later a colleague has extended your base class in three unexpected ways that break your switch logic. Java had no native way to say 'this type hierarchy is closed' — until Java 17 made sealed classes a permanent language feature. This isn't a niche corner of the spec; it's a foundational shift in how Java models domain types, and it lands right at the intersection of two of the most important modern Java features: algebraic data types and pattern matching.
The core problem sealed classes solve is the mismatch between what a library author intends and what the compiler enforces. Before Java 17, 'final' was your only tool for closing a class — but final means no subclasses at all. If you needed exactly three subclasses and no more, you were stuck writing documentation and hoping. Sealed classes give you a middle ground: a fixed, compiler-verified set of permitted subtypes. This makes exhaustiveness checking in switch expressions possible, which is the real killer feature unlocked downstream.
By the end of this article you'll understand exactly how the permits clause works under the hood, why sealed interfaces pair so naturally with records, how the JVM represents sealed type metadata at the bytecode level, where pattern matching in switch consumes this information to eliminate dead code, and the three production mistakes that will cost you hours if you don't know them in advance.
The permits Keyword — Syntax, Rules, and What the Compiler Actually Enforces
A sealed class is declared with the sealed modifier, followed by an optional permits clause that names every direct subtype. There are three things the compiler enforces simultaneously and it's worth being crisp about each one.
First, every class named in permits must directly extend (or implement) the sealed type — not a transitive subclass. If you name Circle in permits but Circle extends Shape2D which extends your sealed Shape, the compiler rejects it.
Second, every permitted subtype must choose one of three modifiers: final (no further extension), sealed (another closed layer), or non-sealed (reopens the hierarchy to the world). This is the most misunderstood rule. Forgetting to add one of these three modifiers is a compile error, not a warning.
Third, the permitted subtypes must live in the same compilation unit, the same package, or — for modules — the same named module as the sealed parent. You cannot extend a sealed class from a different package unless the package is inside the same named module.
One subtlety: if all permitted subtypes are defined in the same source file as the sealed parent, the permits clause is optional — the compiler infers it. This is the pattern you see most with records acting as sealed subclasses.
Here's where it gets real: in a production codebase with hundreds of classes, finding every subtype of an abstract class was a manual grep-and-hope exercise. With sealed, the compiler becomes your inventory system. Add a new permitted subtype? The compiler instantly knows every switch that needs updating. That's not a nice-to-have—it's the difference between a refactoring that takes days and one that takes minutes.
ShapeHierarchy.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
// FILE: ShapeHierarchy.java// Demonstrates the three legal modifiers a permitted subtype must carry.package io.thecodeforge.sealedclasses.section1;
// The sealed parent — permits exactly three direct subtypes.
public sealed class Shape permits Circle, Rectangle, WeirdPolygon {\n\n // A common property all shapes share.\n public abstract double area();\n\n // ----- Permitted subtype 1: FINAL — closes the branch completely -----\n public static final class Circle extends Shape {\n private final double radius;\n\n public Circle(double radius) {\n this.radius = radius;\n }
@Overridepublicdoublearea() {
// πr² — classic formula, no surprises here.returnMath.PI * radius * radius;
}
@OverridepublicStringtoString() {
return"Circle(radius=" + radius + ")";
}
}
// ----- Permitted subtype 2: SEALED — adds another closed layer -----// Rectangle is itself sealed, permitting only Square.publicstatic sealed classRectangleextendsShape permits Rectangle.Square {\n protectedfinaldouble width;\n protectedfinaldouble height;\n\n publicRectangle(double width, double height) {\n this.width = width;\n this.height = height;\n }
@Overridepublicdoublearea() {
return width * height;
}
@OverridepublicStringtoString() {
return"Rectangle(" + width + "x" + height + ")";
}
// Square is a permitted subtype of Rectangle — the chain continues.publicstaticfinalclassSquareextendsRectangle {
publicSquare(double side) {
// A square IS a rectangle where width == height.super(side, side);
}
@OverridepublicStringtoString() {
return"Square(side=" + width + ")";
}
}
}
// ----- Permitted subtype 3: NON-SEALED — deliberately reopens the hierarchy -----// Anyone in any package can now extend WeirdPolygon freely.// Use this escape hatch consciously — it weakens exhaustiveness guarantees.publicstatic non-sealed classWeirdPolygonextendsShape {
privatefinalint sides;
publicWeirdPolygon(int sides) {
this.sides = sides;
}
@Overridepublicdoublearea() {
// Placeholder — real implementation would need more geometry.return0.0;
}
@OverridepublicStringtoString() {
return"WeirdPolygon(sides=" + sides + ")";
}
}
// ----- Demo main — run this to see the hierarchy in action -----publicstaticvoidmain(String[] args) {
// Build a mixed list of shapes.Shape[] shapes = {
newCircle(5.0),
newRectangle(4.0, 6.0),
newRectangle.Square(3.0),
newWeirdPolygon(7)
};
for (Shape shape : shapes) {
// Java 21 pattern matching in switch — but the compiler knows// Shape only has three direct subtypes, so it can verify coverage.String description = switch (shape) {
caseCircle c -> "Circle with area " + String.format("%.2f", c.area());
caseRectangle r -> "Rectangle with area " + String.format("%.2f", r.area());
// WeirdPolygon is non-sealed, so we still need a default// (or a WeirdPolygon case) to satisfy the compiler.default -> "Unknown shape: " + shape;
};
System.out.println(description);
}
}
}
Output
Circle with area 78.54
Rectangle with area 24.00
Rectangle with area 9.00
Unknown shape: WeirdPolygon(sides=7)
Watch Out: non-sealed Breaks Exhaustiveness
The moment any branch of your hierarchy is marked non-sealed, the compiler can no longer prove a switch is exhaustive over the parent type. You'll be forced to add a default arm. Use non-sealed only when you genuinely want third-party extension — not as a shortcut to avoid picking a modifier.
Production Insight
If you forget the permits clause when subtypes are in separate files, the compiler gives a cryptic error: 'class is not allowed to extend sealed class from a different package'.
The permits clause must exactly name every direct subtype.
Rule: always add a package-level comment listing permitted subtypes when they span multiple files.
Key Takeaway
Every permitted subtype must declare final, sealed, or non-sealed.
Missing this modifier is a compile-time error, not a warning.
The compiler forces you to make an explicit decision about extension for each branch.
When to Use Each Modifier for Permitted Subtypes
IfThe subtype should never be extended further
→
UseMark it final — this is the most common choice, especially with records.
IfThe subtype itself needs a fixed set of sub-subtypes
→
UseMark it sealed and add its own permits clause.
IfThe subtype must allow arbitrary extension (e.g., for third-party plugins)
→
UseMark it non-sealed — but be aware this breaks exhaustiveness in switches over the parent.
Sealed Interfaces + Records — The Algebraic Data Type Pattern Java Was Missing
Sealed classes pair most naturally with records to model algebraic data types (ADTs) — the same construct Haskell calls a discriminated union and Rust calls an enum. The pattern is: sealed interface as the 'sum type' header, records as the 'product type' variants. Each record variant carries its own fields, the sealed interface guarantees the closed set, and because records are implicitly final they satisfy the permitted-subtype modifier rule automatically.
This pattern is replacing the old 'abstract class + subclass per variant' approach in modern Java because it's dramatically less code, the fields are immutable by default, equals/hashCode/toString come free, and the compiler can verify switch exhaustiveness without a default case.
Consider a payment processing domain. A payment result is either a success (with a transaction ID and amount), a declined card (with a decline reason), or a processing error (with an exception). That's a perfect ADT: fixed set of outcomes, each carrying different data. Before sealed classes you'd write an abstract class with three subclasses, implement equals yourself five times, and pray nobody added a fourth subclass six months later. With sealed interfaces and records the entire model is eight lines.
The deep value shows up in switch expressions. When the compiler knows your interface is sealed and every permitted type is final or sealed, it can verify you've handled every case — and it will tell you at compile time, not at 3am in production, if you add a fourth variant and forget to update a switch.
One practical tip: if you're converting an existing enum-with-fields antipattern (where you have an enum and then a switch or if-else to extract data), replace it with a sealed interface + records. You'll get better type safety, pattern matching, and no more forgotten branches when a new constant is added.
PaymentResult.javaJAVA
1
2
3
4
5
6
7
8
9
// FILE: PaymentResult.java// Models a payment processing outcome as a sealed interface + records.// This is the ADT pattern — a fixed set of variants, each with its own data.package io.thecodeforge.sealedclasses.section2;
public class PaymentResult {\n\n // The sealed interface acts as the 'type header'.\n // No permits clause needed — all variants are in this file.\n public sealed interface ProcessingResult\n permits ProcessingResult.Success,\n ProcessingResult.Declined,\n ProcessingResult.ProcessingError {\n\n // A convenience method every variant can implement — or we pattern-match outside.\n boolean isTerminal();\n\n // ----- Variant 1: Payment went through -----\n // Records are implicitly final, so they satisfy the sealed constraint automatically.\n record Success(\n String transactionId, // e.g. \"TXN-8821-ABCD\"\n double amountCharged, // the actual amount billed\n String currency // ISO 4217 code: \"USD\", \"EUR\", etc.\n ) implements ProcessingResult {\n\n // Compact canonical constructor — validate on construction.\n public Success {\n if (amountCharged <= 0) {\n throw new IllegalArgumentException(\n \"Charged amount must be positive, got: \" + amountCharged);\n }\n if (transactionId == null || transactionId.isBlank()) {\n throw new IllegalArgumentException(\"Transaction ID must not be blank\");\n }\n }\n\n @Override\n public boolean isTerminal() {\n // A successful charge is always a terminal state.\n return true;\n }\n }\n\n // ----- Variant 2: Card declined by issuer -----\n record Declined(\n String declineCode, // issuer code, e.g. \"INSUFFICIENT_FUNDS\"\n boolean retryable // should the caller attempt again later?\n ) implements ProcessingResult {\n\n @Override\n public boolean isTerminal() {\n // Non-retryable declines are terminal; retryable ones are not.\n return !retryable;\n }\n }\n\n // ----- Variant 3: Internal processing failure -----\n record ProcessingError(\n String errorMessage,\n Throwable cause // the underlying exception, for logging\n ) implements ProcessingResult {\n\n @Override\n public boolean isTerminal() {\n // Errors are always terminal — don't silently retry on errors.\n return true;\n }\n }\n }\n\n // Simulates a payment gateway response based on card number suffix.\n public static ProcessingResult charge(String cardNumberSuffix, double amount) {\n return switch (cardNumberSuffix) {\n case \"4242\" -> new ProcessingResult.Success(\"TXN-\" + System.nanoTime(), amount, \"USD\");\n case \"0002\" -> new ProcessingResult.Declined(\"INSUFFICIENT_FUNDS\", true);\n case \"9999\" -> new ProcessingResult.Declined(\"STOLEN_CARD\", false);\n default -> new ProcessingResult.ProcessingError(\n \"Gateway timeout\",\n new RuntimeException(\"Connection refused\"));\n };\n }\n\n public static void main(String[] args) {\n String[] testCards = {\"4242\", \"0002\", \"9999\", \"1234\"};\n\n for (String card : testCards) {\n ProcessingResult result = charge(card, 99.95);\n\n // Pattern matching switch — NO default needed because all permitted\n // types are records (implicitly final). The compiler verifies exhaustiveness.\n String summary = switch (result) {\n case ProcessingResult.Success s ->\n String.format(\"✅ Charged $%.2f — TXN: %s\", s.amountCharged(), s.transactionId());\n\n case ProcessingResult.Declined d ->\n String.format(\"❌ Declined (%s) — Retryable: %s\", d.declineCode(), d.retryable());\n\n case ProcessingResult.ProcessingError e ->\n String.format(\"💥 Error: %s\", e.errorMessage());\n // No default needed! The compiler KNOWS these three are all there are.\n };\n\n System.out.printf(\"Card ...%s → %s (terminal=%s)%n\",\n card, summary, result.isTerminal());\n }\n }\n}","output": "Card ...4242 → ✅ Charged $99.95 — TXN: TXN-843291045123 (terminal=true)\nCard ...0002 → ❌ Declined (INSUFFICIENT_FUNDS) — Retryable: true (terminal=false)\nCard ...9999 → ❌ Declined (STOLEN_CARD) — Retryable: false (terminal=true)\nCard ...1234 → 💥 Error: Gateway timeout (terminal=true)"
}
JVM Internals — How Sealed Metadata Lives in Bytecode and What That Means for Reflection
Sealed classes aren't purely a compiler trick — the JVM carries the sealing information at runtime via a new class file attribute introduced in Java 17: PermittedSubclasses. You can inspect this at runtime using the Reflection API, which has implications for frameworks that generate code or proxies dynamically.
The `Class` API gained two new methods:isSealed() returns true if the class or interface is sealed, and permittedSubclasses() returns an array of ClassDesc objects — one for each permitted subtype. These descriptors are resolved lazily, so calling permittedSubclasses() doesn't load the subtype classes.
Why does this matter in production? Three scenarios: First, serialization frameworks like Jackson or Kryo need to know the closed set of subtypes to build discriminated union deserializers — they can now read this from the class file automatically instead of requiring @JsonSubTypesannotations. Second, dependency injection containers can discover all implementations of a sealed interface without classpath scanning. Third, bytecode-level proxying tools (CGLIB, ByteBuddy) must respect the sealed constraint — if you ask ByteBuddy to create a runtime subclass of a sealed class it will fail unless the generated class is listed in permits, which it can't be at runtime. This is the most common gotcha when mixing sealed classes with older AOP frameworks.
The PermittedSubclasses attribute is stored in the constant pool as a list of class info entries. Each entry is a simple UTF-8 class name. The JVM verifier enforces that no class can be loaded with a superclass or superinterface that is sealed unless that class appears in the PermittedSubclasses attribute — this check happens at class loading time, not compilation time, so it catches classes compiled against an older version of the sealed parent.
Also note: if you're using Java modules, the isSealed() method respects module-level accessibility. A sealed class in an exported package is visible, but internal sealed classes may have their permittedSubclasses() return only accessible subtypes.
SealedReflectionInspector.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// FILE: SealedReflectionInspector.java// Shows how to read sealed metadata at runtime via the Reflection API.// Useful for building framework integrations or diagnostic tooling.package io.thecodeforge.sealedclasses.section3;
import java.lang.constant.ClassDesc;
import java.util.Arrays;
import java.util.List;
publicclassSealedReflectionInspector {
// A minimal sealed hierarchy to inspect.
sealed interfaceNotification
permits Notification.EmailNotification,
Notification.SmsNotification,
Notification.PushNotification {\n Stringrecipient();\n }
record EmailNotification(String recipient, String subject)
implementsNotification {}
record SmsNotification(String recipient, String messageBody)
implementsNotification {}
record PushNotification(String recipient, String deviceToken, String payload)
implementsNotification {}
publicstaticvoidmain(String[] args) {
Class<Notification> notificationClass = Notification.class;
// ----- 1. Check if the type is sealed -----System.out.println("Is Notification sealed? " + notificationClass.isSealed());
System.out.println("Is String sealed? " + String.class.isSealed());
System.out.println();
// ----- 2. Retrieve permitted subtypes -----// Returns ClassDesc[] — lightweight descriptors that don't trigger class loading.ClassDesc[] permitted = notificationClass.permittedSubclasses();
System.out.println("Permitted subtypes of Notification:");
for (ClassDesc descriptor : permitted) {
// descriptor.displayName() gives a human-readable class name.System.out.println(" → " + descriptor.displayName());
}
System.out.println();
// ----- 3. Use reflection to build a dispatcher — no hardcoded instanceof -----// This simulates what a serialization framework might do at startup.List<Notification> events = List.of(
newEmailNotification("alice@example.com", "Your order shipped"),
newSmsNotification("+1-555-0100", "OTP: 847291"),
new PushNotification("bob@example.com", "device-token-xyz", "{\\\"alert\\\":\\\"New message\\\"}\")\n );\n\n System.out.println(\"Dispatching \" + events.size() + \" notifications:\");\n for (Notification event : events) {\n // getClass().getSimpleName() here — in a real framework you'd\n // use the ClassDesc array to build a lookup map at initialization.\n System.out.printf(\" [%s] → recipient: %s%n\",\n event.getClass().getSimpleName(),\n event.recipient());\n }\n System.out.println();\n\n // ----- 4. Show that the JVM enforces sealing at load time -----\n // Demonstrating isSealed() on a record subtype.\n System.out.println(\"Is EmailNotification sealed? \" +\n EmailNotification.class.isSealed());\n System.out.println(\"Is EmailNotification final? \" +\n java.lang.reflect.Modifier.isFinal(\n EmailNotification.class.getModifiers()));\n // Records are implicitly final, so isSealed() is false and isFinal() is true.\n }\n}","output": "Is Notification sealed? true\nIs String sealed? false\n\nPermitted subtypes of Notification:\n → SealedReflectionInspector$EmailNotification\n → SealedReflectionInspector$SmsNotification\n → SealedReflectionInspector$PushNotification\n\nDispatching 3 notifications:\n [EmailNotification] → recipient: alice@example.com\n [SmsNotification] → recipient: +1-555-0100\n [PushNotification] → recipient: bob@example.com\n\nIs EmailNotification sealed? false\nIs EmailNotification final? true"
}
Pattern Matching + Sealed Classes — Exhaustiveness, Guarded Patterns, and the Switch Completeness Contract
The biggest payoff of sealed classes comes when you combine them with the pattern matching switch expressions finalized in Java 21 (which builds directly on the sealed metadata introduced in 17). When the compiler sees a switch over a sealed type and every permitted subtype has a matching case, it marks the switch as 'exhaustive' and removes the requirement for a default arm. If you later add a new permitted subtype and forget to update a switch somewhere, the code won't compile. That's a compile-time safety net you simply can't get with open hierarchies.
Guarded patterns let you add when conditions to a case arm — the compiler still verifies that all patterns together cover every possible runtime value. Think of it as the compiler doing boolean coverage analysis, not just type coverage analysis.
Null handling is the trickiest edge case. A switch over a sealed type will throw NullPointerException by default if the value is null — just like the old switch. You need an explicit case null arm, or a case null, default combined arm. Forgetting this is the #1 production bug in early sealed-class codebases.
Performance note: the JIT compiler can use sealed metadata to devirtualize virtual dispatch more aggressively. When a method is called on a sealed type with three final subtypes, the JIT can generate an inlined type check rather than a vtable lookup. In hot paths with small sealed hierarchies this produces meaningfully faster code — benchmark data from the Valhalla project shows 10-15% throughput improvements on tight dispatch loops compared to open polymorphism.
One more nuance: sealed interfaces used as a method parameter type give the JIT a perfect target for inlining. If you have a method that accepts a sealed interface with three known implementations, the JIT can inline all three targets and check each at runtime rather than a vtable call. This matters for high-throughput code paths like payment gateways or protocol parsers.
ShippingCalculator.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// FILE: ShippingCalculator.java// Demonstrates exhaustive pattern matching switch over a sealed hierarchy,// including guarded patterns and null safety.package io.thecodeforge.sealedclasses.section4;
public class ShippingCalculator {\n\n // Sealed interface modelling a shipping method — four variants, all final.\n public sealed interface ShippingMethod\n permits ShippingMethod.StandardGround,\n ShippingMethod.ExpressAir,\n ShippingMethod.OvernightPriority,\n ShippingMethod.DigitalDelivery {\n\n // A contract all variants must satisfy.\n boolean requiresPhysicalAddress();\n\n record StandardGround(int estimatedDays) implements ShippingMethod {\n @Override public boolean requiresPhysicalAddress() { return true; }
}
record ExpressAir(int estimatedDays, boolean insured) implementsShippingMethod {\n @OverridepublicbooleanrequiresPhysicalAddress() { returntrue; }
}
record OvernightPriority(String cutoffTime) implementsShippingMethod {
@OverridepublicbooleanrequiresPhysicalAddress() { returntrue; }
}
// Digital delivery — no physical address needed.
record DigitalDelivery(String downloadUrl) implementsShippingMethod {
@OverridepublicbooleanrequiresPhysicalAddress() { returnfalse; }
}
}
/**
* Calculates shipping cost for a package.
*
* @param method the chosen shipping method (must not be null — see null guard below)
* @param weightKg package weight in kilograms
* @return calculated shipping cost in USD
*/
public static double calculateCost(ShippingMethod method, double weightKg) {\n // Explicit null case — without this, a null method throws NullPointerException\n // BEFORE reaching any type pattern. This is the most common sealed-switch gotcha.\n return switch (method) {\n case null ->\n throw new IllegalArgumentException(\"Shipping method must not be null\");\n\n // Guarded pattern: StandardGround is cheap only if weight is under 5kg.\n case ShippingMethod.StandardGround sg when weightKg <= 5.0 ->\n 5.99 + (weightKg * 0.50);\n\n // Same type, different guard — handles heavier packages.\n case ShippingMethod.StandardGround sg ->\n 5.99 + (weightKg * 0.80); // penalty rate for heavy ground shipments\n\n // Destructure the record's components directly in the case.\n case ShippingMethod.ExpressAir(int days, boolean insured) ea\n when insured ->\n // Insurance adds a flat 15% surcharge.\n (12.99 + (weightKg * 1.50)) * 1.15;\n\n case ShippingMethod.ExpressAir ea ->\n 12.99 + (weightKg * 1.50);\n\n case ShippingMethod.OvernightPriority op ->\n // Overnight is a flat rate regardless of weight.\n 39.99;\n\n case ShippingMethod.DigitalDelivery dd ->\n // Digital goods ship free — no weight cost.\n 0.00;\n\n // NO default needed — the compiler verified all 4 permitted types are covered.\n // If we add a 5th variant to the sealed interface, THIS switch will fail to compile\n // until we add a matching case. That's the safety guarantee.\n };\n }\n\n public static void main(String[] args) {\n var orders = new Object[][] {\n { new ShippingMethod.StandardGround(5), 3.0 },\n { new ShippingMethod.StandardGround(5), 8.0 },\n { new ShippingMethod.ExpressAir(2, true), 2.5 },\n { new ShippingMethod.ExpressAir(2, false), 2.5 },\n { new ShippingMethod.OvernightPriority(\"5:00 PM\"), 1.0 },\n { new ShippingMethod.DigitalDelivery(\"https://dl.example.com/v2.zip\"), 0.0 }\n };\n\n System.out.printf(\"%-40s %8s%n\", \"Shipping Method\", \"Cost\");\n System.out.println(\"-\".repeat(50));\n\n for (Object[] order : orders) {\n ShippingMethod method = (ShippingMethod) order[0];\n double weight = (double) order[1];\n double cost = calculateCost(method, weight);\n\n System.out.printf(\"%-40s $%7.2f%n\",\n method.getClass().getSimpleName() + \" (\" + weight + \"kg)\", cost);\n }\n\n // Show null guard in action.\n try {\n calculateCost(null, 1.0);\n } catch (IllegalArgumentException e) {\n System.out.println(\"\\nNull guard caught: \" + e.getMessage());\n }\n }\n}","output": "Shipping Method Cost\n--------------------------------------------------\nStandardGround (3.0kg) $ 7.49\nStandardGround (8.0kg) $12.39\nExpressAir (2.5kg) $19.82\nExpressAir (2.5kg) $16.74\nOvernightPriority (1.0kg) $39.99\nDigitalDelivery (0.0kg) $ 0.00\n\nNull guard caught: Shipping method must not be null"
}
Migration to Sealed Classes: Refactoring Open Hierarchies
So you have an existing abstract class or interface with a known, finite set of implementations. You want to convert it to sealed. How do you do it without breaking existing code?
First, identify all direct subtypes. You need a complete list — the compiler will enforce that after you add sealed and permits. If any subtype is outside your control (e.g., third-party library), you cannot seal the hierarchy unless you mark that subtype non-sealed. But that defeats the purpose. In practice, sealed hierarchies work best when you own all implementations.
Second, decide whether each subtype should be final, sealed, or non-sealed. Most existing subtypes are final already — just add the keyword. If a subtype is not final and you don't want to lock it down, you have two choices: make it sealed (if you know its subclasses) or non-sealed (if you don't). If you choose non-sealed, you lose exhaustiveness — but you may decide that's acceptable for that particular branch.
Third, change the parent class/interface from abstract or interface to sealed abstract or sealed interface. Add permits with the list. Remove any deprecated subtype references.
Fourth, compile and fix any missing modifiers on subtypes. The compiler will tell you exactly which one lacks final, sealed, or non-sealed.
Fifth, update all switch statements and expressions over the parent type. With the hierarchy now sealed and with final subtypes, you can remove default arms. Add case null if null is possible.
Common pitfalls: forgetting to export the sealed type from its module (if using modules), breaking serialization if subtypes are in different modules, and breaking field injection in DI frameworks that rely on subclassing.
Migration is safest when done incrementally: start by sealing a leaf class, then move up. The compiler won't let you seal a parent until all its children are accounted for — that's the point.
Real-world example: we migrated a legacy order processing hierarchy in a fintech app. The abstract Order class had five known subclasses. After sealing, we discovered two obsolete subclasses never used in production. The compiler forced us to either remove them or list them. We removed them, cleaned up 300 lines of dead code, and the switch statements became fully exhaustive. That's the kind of refactoring sealed classes enable.
HierarchyMigration.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// FILE: HierarchyMigration.java// Shows step-by-step conversion of an open abstract class to sealed.package io.thecodeforge.sealedclasses.section5;
// ----- BEFORE: Open abstract class with known subtypes -----// public abstract class Vehicle {// public abstract double fuelEfficiency();// }//// public class Car extends Vehicle { ... }// public class Truck extends Vehicle { ... }// public class Motorcycle extends Vehicle { ... }// ----- AFTER: Sealed class with explicit permits -----// All three subtypes are now final — they are the only ones allowed.
public sealed class Vehicle permits Car, Truck, Motorcycle {\n public abstract double fuelEfficiency();\n\n // Final subtypes — cannot be extended further.\n public static final class Car extends Vehicle {\n private final double milesPerGallon;\n public Car(double mpg) { this.milesPerGallon = mpg; }
@OverridepublicdoublefuelEfficiency() { return milesPerGallon; }
}
publicstaticfinalclassTruckextendsVehicle {
privatefinaldouble gallonsPer100Miles;
publicTruck(double gphm) { this.gallonsPer100Miles = gphm; }
@OverridepublicdoublefuelEfficiency() { return100.0 / gallonsPer100Miles; }
}
publicstaticfinalclassMotorcycleextendsVehicle {
privatefinaldouble milesPerGallon;
publicMotorcycle(double mpg) { this.milesPerGallon = mpg; }
@OverridepublicdoublefuelEfficiency() { return milesPerGallon; }
}
// Migration-compatible switch — no default, handles null.publicstaticdoublefuelCost(Vehicle v, double distance) {
returnswitch (v) {
casenull -> thrownewIllegalArgumentException("Vehicle is null");
caseCar c -> distance / c.fuelEfficiency() * 3.50;
caseTruck t -> distance * t.gallonsPer100Miles / 100.0 * 4.00;
caseMotorcycle m -> distance / m.fuelEfficiency() * 3.00;
};
}
}
Start from the Leaves Upward
When migrating a large hierarchy, seal the leaf classes first (make them final). Then gradually add sealed to parents. The compiler will guide you — the first time you compile with a sealed parent, every missing permitted subtype or missing modifier becomes a compile error. That's better than a runtime surprise.
Production Insight
Third-party subtypes cannot be listed in permits if you don't own them.
If you must seal a hierarchy that includes external subtypes, mark them non-sealed.
But be warned: non-sealed subtypes remove exhaustiveness for the entire parent.
Key Takeaway
Migration to sealed is incremental: seal leaves first, then parents.
You must own all permitted subtypes, or mark them non-sealed.
The compiler enforces completeness — use it as a guide, not a blocker.
Migration Decision Tree: Should You Seal This Hierarchy?
IfYou own all current subtypes and they are unlikely to change
→
UseSeal the hierarchy. Mark subtypes final. This gives you exhaustiveness and JIT devirtualization.
IfYou own all subtypes but a few have unknown subclasses
→
UseSeal the parent, mark those subtypes non-sealed. You get partial exhaustiveness — branches you control are closed.
IfYou do NOT own some subtypes (e.g., from a library)
→
UseYou cannot seal the hierarchy unless you fork the library or use an adapter pattern. Consider leaving the hierarchy open and using documentation + testing instead.
Sealed Classes, Records, and Enums: Choosing the Right Modeling Tool
Java now offers three ways to define a fixed set of types: enums, sealed classes/records, and sealed interfaces/records. When should you use each?
Enums are best when each variant is a singleton with no additional data beyond the constant name and ordinal. Examples: days of the week, order status codes, state machine states. Enums cannot have per-variant fields (without ugly workarounds) and cannot be extended. They are perfect for simple discriminations.
Sealed interface + records (the ADT pattern) is best when each variant carries significant, possibly different, data. Examples: payment results, AST nodes, API response types. Each record can have its own fields, constructors, and methods. The compiler checks exhaustiveness in switch.
Sealed abstract class + regular subclasses is a fallback when you need mutable state, shared behaviour via inheritance, or when records aren't suitable (e.g., JPA entities). But records are preferred for value types.
Sealed interface + concrete classes (non-records) is used when some variants need mutable state or methods that records cannot provide. You lose the auto-generated equals/hashCode/toString.
Key rule: prefer sealed interface + records for new code. It gives you immutable data, exhaustive switches, and minimal boilerplate. Only fall back to abstract class when you need inheritance of mutable state or complex hierarchy.
Enums are not a replacement for sealed types; they serve a different purpose. Use enums for fixed constants, sealed for fixed types.
A common mistake I've seen: teams replace enums with sealed records thinking they're "upgrading". Don't. If your variants carry no data, an enum is simpler, serializes better, and has built-in ordinal numbering. Sealed records shine when each variant has its own shape.
ModelingComparison.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// FILE: ModelingComparison.java// Compares enum, sealed interface+records, and sealed abstract class approaches.package io.thecodeforge.sealedclasses.section6;
public class ModelingComparison {\n\n // ---- Scenario 1: Simple constant with no data — use enum ----\n public enum OrderStatus {\n PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED\n }// ---- Scenario 2: Variants with different data — use sealed interface + records ----public sealed interfacePaymentEvent
permits PaymentEvent.PaymentReceived,
PaymentEvent.PaymentFailed,
PaymentEvent.PaymentRefunded {\n StringtransactionId();\n\n record PaymentReceived(String transactionId, double amount) implementsPaymentEvent {}
record PaymentFailed(String transactionId, String reason, int errorCode) implementsPaymentEvent {}
record PaymentRefunded(String transactionId, double refundAmount, String reason) implementsPaymentEvent {}
}
// ---- Scenario 3: Mutable hierarchy with shared state — use sealed abstract class ----
public sealed abstract class AbstractLogger permits FileLogger, ConsoleLogger {\n protected String prefix; // shared mutable state\n public abstract void log(String message);\n }publicstaticfinalclassFileLoggerextendsAbstractLogger {
publicFileLogger(String prefix) { this.prefix = prefix; }
@Overridepublicvoidlog(String message) {
System.out.println(prefix + " [FILE] " + message);
}
}
publicstaticfinalclassConsoleLoggerextendsAbstractLogger {
publicConsoleLogger(String prefix) { this.prefix = prefix; }
@Overridepublicvoidlog(String message) {
System.out.println(prefix + " [CONSOLE] " + message);
}
}
// ---- Usage ----publicstaticvoidmain(String[] args) {
// Enum: simple constantOrderStatus status = OrderStatus.SHIPPED;
System.out.println("Order: " + status);
// Sealed interface + records: exhaustive switch without defaultPaymentEvent event = newPaymentEvent.PaymentReceived("TXN-1234", 99.99);
String desc = switch (event) {
casePaymentEvent.PaymentReceived r -> "Received $" + r.amount();
casePaymentEvent.PaymentFailed f -> "Failed: " + f.reason();
casePaymentEvent.PaymentRefunded r -> "Refunded $" + r.refundAmount();
};
System.out.println(desc);
// Sealed abstract classAbstractLogger logger = newFileLogger("[MYAPP]");
logger.log("Application started");
}
}
Output
Order: SHIPPED
Received $99.99
[MYAPP] [FILE] Application started
Enum vs Sealed: The Mental Model
Use enum when the variants are just labels with no additional fields.
Use sealed interface + records when each variant has its own data structure (ADT).
Use sealed abstract class when you need mutable shared state or JavaBean-style classes.
Records are always final; sealed abstract classes can be made non-sealed if needed.
Production Insight
Using an enum where you need per-variant fields leads to ugly if-else chaining and lost compile-time safety.
Using sealed records where an enum would do adds unnecessary complexity.
The rule: enum for constants, sealed records for types.
Key Takeaway
Use sealed interface + records as the default for domain ADTs.
Reserve enums for simple constant sets without extra data.
Sealed abstract classes are a fallback when you need mutable state.
● Production incidentPOST-MORTEMseverity: high
Spring AOP Proxy Failure with Sealed Classes
Symptom
Application crashes on startup with a JVM VerifyError: 'Cannot inherit from sealed class'.
Assumption
We assumed that applying @Transactional to the sealed base class would work like an abstract class — Spring would create a CGLIB proxy subclass.
Root cause
Spring's default AOP proxy mode uses CGLIB to create runtime subclasses. Sealed classes explicitly forbid subclassing from unlisted classes. CGLIB-generated subclasses are not in the permits list, so the JVM rejects the proxy at load time.
Fix
Set spring.aop.proxy-target-class=false in application.properties to force JDK dynamic proxies (interface-based). Alternatively, inject by the sealed interface type and annotate concrete implementations instead of the sealed type.
Key lesson
Sealed classes cannot be proxied by subclassing at runtime.
Always use interface-based proxying when mixing AOP with sealed types.
Inject by the sealed interface, not by a concrete permitted subtype.
Production debug guideCommon sealed class issues and their immediate actions4 entries
Symptom · 01
Compilation error: 'class is not allowed to extend sealed class'
→
Fix
Verify the extending class is listed in the permits clause of the sealed parent. If subtypes are in the same file, the permits clause is optional but must match the actual subtypes in that file. Check the class is in the same module (or package) and has one of the three modifiers (final, sealed, non-sealed).
Symptom · 02
Switch expression requires a default arm despite covering all permitted subtypes
→
Fix
Check if any permitted subtype is declared non-sealed. Non-sealed branches break exhaustiveness. Either add a case for that subtype's unknown children, or mark the subtype as final if it should not be extended further.
Symptom · 03
Cannot subclass final class or record when using AOP
→
Fix
Switch to JDK dynamic proxies by setting spring.aop.proxy-target-class=false, or avoid AOP on the sealed hierarchy entirely. Use the sealed interface as the injection type and apply advice only to concrete subtypes.
Symptom · 04
ClassCastException when casting a subtype from a different module
→
Fix
Verify the sealed parent class is exported from its module (module-info.java exports clause). Permitted subtypes must be in the same module (or same package if not modular). If they are in a different module, the parent must export the package containing the sealed type.
★ Sealed Class Debug Cheat SheetQuick commands and fixes for the most common sealed class problems in production.
JVM VerifyError: Cannot inherit from sealed class−
Immediate action
Check if a framework proxy or bytecode manipulation (CGLIB, ByteBuddy) is trying to subclass a sealed class. Disable subclass-based proxying.
Commands
java -verbose:class 2>&1 | grep -i 'sealed' # identify the offending class loading
mvn dependency:tree | grep 'spring-aop' # check Spring AOP version
Fix now
Set spring.aop.proxy-target-class=false in application.properties and restart. If using ByteBuddy, add .with(AgentBuilder.RedefinitionStrategy.RETRANSFORM).disableClassFormatChanges().
Compilation error: class is not allowed to extend sealed class from a different package+
Immediate action
Move the permitted subtype into the same package as the sealed parent, or ensure both are in the same named module.
Commands
javap -verbose SealedClass.class | grep 'PermittedSubclasses' # see current permits
grep -r 'permits' src/main/java/ # find all permits clauses
Fix now
Add the subtype class to the permits clause with its fully qualified name. If in the same file, remove the permits clause (compiler infers it).
NullPointerException in switch over sealed type+
Immediate action
Add 'case null' to your switch expression. A sealed switch without a null case throws NPE on null input.
Commands
grep -rn 'switch.*sealed' --include='*.java' src/ # find all sealed switches
Find all switch expressions over sealed types and add null handling.
Fix now
Add 'case null -> throw new IllegalArgumentException(...)' or 'case null, default -> ...' to merge null handling with the default branch.
Sealed Classes vs Abstract Classes vs Enums
Feature / Aspect
Sealed Class/Interface
Abstract Class (open)
enum
Extensibility
Fixed set of permitted subtypes
Unlimited — any subclass anywhere
No extension — variants are singletons
Each variant's data
Each subtype has own fields (esp. with records)
Each subtype has own fields
Limited — enum constants share the type's fields
Compile-time exhaustiveness
Yes — switch needs no default over sealed type
No — default always required
Yes — but only for simple enum constants
Pattern matching support
Full (Java 21+) — type & guarded patterns
Partial — needs default arm
Full — but no per-variant data decomposition
Null safety in switch
Explicit case null required
Explicit case null required
NPE on null — same behavior
Runtime introspection
isSealed(), permittedSubclasses()
No sealed metadata
values(), ordinal(), name()
Proxy/AOP compatibility
Problematic with CGLIB if final records used
Works well — subclassing allowed
Cannot be subclassed — same issue
Typical use case
Domain ADTs, result types, AST nodes
Template method patterns, shared state
Fixed categorical constants, flags
Key takeaways
1
Sealed classes give you compile-time control over type hierarchies
the compiler enforces a fixed set of permitted subtypes.
2
Every permitted subtype must be final, sealed, or non-sealed; omitting this modifier is a compile error.
3
Sealed + records = algebraic data types in Java, enabling exhaustive pattern matching without default arms.
4
The JVM carries sealing info in the PermittedSubclasses attribute; frameworks can use isSealed() for introspection.
5
CGLIB proxies fail on sealed classes
use interface-based JDK proxies or inject by the sealed type.
Common mistakes to avoid
5 patterns
×
Forgetting the modifier on a permitted subtype
Symptom
Compilation error: 'The type [subtype] that implements the sealed interface/class must be either final, sealed, or non-sealed'. The error includes the exact missing modifier.
Fix
Add final, sealed, or non-sealed to the class declaration. For records, final is implied and cannot be added explicitly — remove the modifier if it's a record (records are already final).
×
Applying AOP advice (e.g., @Transactional) directly to a sealed class
Symptom
Application fails at startup with a JVM VerifyError: 'Cannot inherit from sealed class' or 'Cannot subclass final class' for records.
Fix
Switch to interface-based JDK dynamic proxies by setting spring.aop.proxy-target-class=false. Alternatively, apply @Transactional to individual concrete subtypes instead of the sealed parent, or use a design-pattern that avoids AOP on sealed types.
×
Using non-sealed subtypes without understanding the exhaustiveness cost
Symptom
Switch expressions over the sealed parent suddenly require a default arm, even if you covered all known subtypes. The compiler forces you to handle unknown subclasses of the non-sealed branch.
Fix
If you don't need third-party extension, make the subtype final or sealed instead of non-sealed. If you must allow extension, you must either add a default arm or a case that matches the non-sealed subtype's unknown children (e.g., using case NonSealedSubtype ns -> handle(ns); but that doesn't cover deeper subclasses).
×
Placing permitted subtypes in a different module without proper exports
Symptom
Compilation error: 'class is not allowed to extend sealed class from a different package'. At runtime, ClassNotFoundException or illegal access errors.
Fix
Ensure the sealed parent class is in a package that is exported from its module (using the exports directive in module-info.java). If the subtypes are in a different module, the parent's package must be exported, and the subtypes' module must read the parent's module. Alternatively, move all subtypes into the same package as the sealed parent.
×
Omitting `case null` in a sealed switch
Symptom
NullPointerException is thrown at runtime when a null value is passed to a switch expression over a sealed type.
Fix
Always include an explicit case null arm if null is possible. If null is not expected, add case null -> throw new IllegalArgumentException(...) for fail-fast behaviour.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01JUNIOR
What is a sealed class in Java 17? Explain the `permits` clause and the ...
Q02SENIOR
How do sealed classes interact with pattern matching? What is the compil...
Q03SENIOR
Describe the JVM-level representation of sealed classes. How does the `P...
Q01 of 03JUNIOR
What is a sealed class in Java 17? Explain the `permits` clause and the three modifiers permitted subtypes must carry.
ANSWER
A sealed class restricts which other classes can extend it. It's declared with the sealed modifier and an optional permits clause listing every direct subtype. Each permitted subtype must be declared final, sealed, or non-sealed. final means it cannot be extended further; sealed means it itself has a fixed set of subtypes; non-sealed reopens the hierarchy. The compiler enforces that every class named in permits directly extends the sealed parent and has one of these modifiers. If all subtypes are in the same source file, permits can be omitted and the compiler infers them.
Q02 of 03SENIOR
How do sealed classes interact with pattern matching? What is the compile-time exhaustiveness guarantee?
ANSWER
When you switch over a sealed type and all permitted subtypes are final (or sealed with their own final leaves), the compiler can verify that every possible runtime type has a matching case. It then allows you to omit a default arm. This means if you later add a new permitted subtype to the sealed hierarchy and forget to update a switch, the switch fails at compile time — not at runtime. This is the key safety benefit. Note: non-sealed subtypes break exhaustiveness; you must then include a default arm.
Q03 of 03SENIOR
Describe the JVM-level representation of sealed classes. How does the `PermittedSubclasses` attribute affect frameworks like Spring or Hibernate?
ANSWER
The JVM stores a PermittedSubclasses attribute in the class file, listing the names of all permitted subtypes. At runtime, Class.isSealed() and Class.permittedSubclasses() (returning ClassDesc[]) allow reflection access. This attribute is checked during class loading — if a class tries to extend a sealed class without being in the list, the JVM throws a VerifyError. For frameworks: CGLIB and ByteBuddy cannot create runtime proxies that subclass sealed classes, because the generated class isn't in the permits list. Spring's AOP with CGLIB will fail with VerifyError. Frameworks that use reflection (Jackson, etc.) can use permittedSubclasses() for auto-discovery instead of annotations.
01
What is a sealed class in Java 17? Explain the `permits` clause and the three modifiers permitted subtypes must carry.
JUNIOR
02
How do sealed classes interact with pattern matching? What is the compile-time exhaustiveness guarantee?
SENIOR
03
Describe the JVM-level representation of sealed classes. How does the `PermittedSubclasses` attribute affect frameworks like Spring or Hibernate?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
Can I seal a class that already has subclasses outside the permits list?
No—the compiler enforces that all existing direct subtypes must be in the permits list. Any subclass not listed will cause a compile error. You must either add it to permits, mark it non-sealed, or remove it.
Was this helpful?
02
What happens if I use `non-sealed` on a subtype?
The hierarchy is reopened for that branch. The compiler can no longer guarantee exhaustiveness over the sealed parent, so you'll need a default arm in switches. Use non-sealed sparingly.
Was this helpful?
03
Can a sealed class be abstract?
Yes. Write sealed abstract class. It can have abstract methods and constructors. The sealed constraint applies regardless.
Was this helpful?
04
Do sealed classes work with Java modules?
Yes, with extra rules: the sealed type must be exported from its module (exports), and permitted subtypes must be in the same module (or same package if no module). Cross-module subtypes require correct requires directives.
Was this helpful?
05
Is there a performance penalty for using sealed classes?
No—the opposite. The JIT can devirtualize method dispatch on sealed hierarchies with final subtypes, leading to 10-15% throughput gains on hot paths. The compiler overhead is negligible.