Java Tutorial
🔍

Java Sealed Classes

Java Sealed Classes

A sealed class or interface lets its author write down, once and for all, the complete list of types allowed to extend or implement it - and have the compiler enforce that list everywhere, forever. Declaring sealed interface Shape permits Circle, Rectangle does not just suggest that Circle and Rectangle are the only shapes; it makes any other class claiming to be a Shape a compile error. The payoff shows up the moment code needs to handle every kind of Shape: a switch over a sealed type can know, at compile time, that Circle and Rectangle are the only possibilities - no default branch standing in for "something I didn't think of."

What Is a Sealed Class?

A sealed class or interface is declared with the sealed modifier and a permits clause naming every type allowed to directly extend or implement it. This feature was previewed in Java 15 and 16, and finalized in Java 17. Every type named in permits must itself declare, explicitly, what happens to the closed-hierarchy guarantee from that point onward - by being final, sealed (with its own narrower permits), or non-sealed.

sealed interface Shape permits Circle, Rectangle {}

record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}

// class Triangle implements Shape {}   <- COMPILE ERROR
// Triangle is not in Shape's permits clause

A note on Java versions, since this matters for the examples ahead: the sealed / permits / non-sealed / final declarations themselves are a Java 17 feature. The most natural way to USE a sealed type - a switch that exhaustively handles every permitted subtype with no default - relies on pattern matching for switch, which was finalized in Java 21 (JEP 441), after several preview rounds starting in 17. On Java 17 through 20, sealed declarations compile and the closed-hierarchy guarantee is enforced at the declaration level, but the switch-based exhaustiveness shown throughout this article specifically needs Java 21 (or the relevant preview flag on earlier versions).

Basic Overview - The Three Things a Permitted Subtype Can Be

DECLARING THE SEALED TYPE
  sealed interface Shape permits Circle, Rectangle { }

  Fresher view  : a closed list, written by the author, of every type
                  allowed to be a Shape - nothing else, ever
  Deeper view   : 'permits' can be OMITTED if every permitted type is
                  declared in the SAME source file - the compiler
                  infers the list from what it sees there

EVERY DIRECT SUBTYPE MUST CHOOSE EXACTLY ONE OF THREE OPTIONS:

  final        - "the hierarchy ends here"
    final record Circle(double radius) implements Shape {}
    Fresher view  : Circle cannot be extended by anything, ever
    Deeper view   : records are implicitly final already - writing
                    'final' on a record is redundant but harmless

  sealed       - "extendable, but only by MY OWN closed list"
    sealed interface Polygon extends Shape permits Triangle, Square {}
    Fresher view  : Polygon is itself a closed set, nested inside Shape's
    Deeper view   : multi-level sealed hierarchies are fully supported -
                    a switch over Shape must still account for every
                    concrete type reachable through Polygon too

  non-sealed   - "the closed guarantee deliberately ENDS here"
    non-sealed class ElectricVehicle implements Vehicle {}
    Fresher view  : from ElectricVehicle onward, any class anywhere
                    can extend it normally
    Deeper view   : this is an explicit, visible DECISION recorded in
                    the type itself - "Vehicle is closed, except this
                    one branch is intentionally open"

EXHAUSTIVE switch OVER A SEALED TYPE (Java 21)
  Fresher view  : a switch handling every permitted subtype needs no
                  default branch - the compiler already knows there
                  is nothing else
  Deeper view   : adding a new permitted subtype later, without
                  updating every switch that should handle it, becomes
                  a COMPILE ERROR at each one - not a runtime surprise
                  discovered later

A fresher mainly needs the top two boxes - declare the closed list, and know that final is the default expectation for "this branch ends here." The non-sealed and exhaustive-switch boxes are where sealed types stop being "a slightly stricter interface" and start being the foundation of a pattern - modeling a fixed set of "shapes" a value can take, with the compiler checking that every shape is handled, everywhere.

Why Sealed Classes Were Introduced

Before sealed types, every interface and every non-final class was, by definition, open - any class anywhere, in any package, could implement or extend it. This was rarely a problem for types meant to be extended freely, but it was a real gap for types meant to represent "one of a few specific things and nothing else."

1// BEFORE - a plain interface, open to anyone 2 3interface Shape {} 4 5record Circle(double radius) implements Shape {} 6record Rectangle(double width, double height) implements Shape {} 7 8// Nothing stops this, anywhere in the codebase, ever: 9// record Triangle(double base, double height) implements Shape {} 10 11double area(Shape shape) { 12 if (shape instanceof Circle c) { 13 return Math.PI * c.radius() * c.radius(); 14 } else if (shape instanceof Rectangle r) { 15 return r.width() * r.height(); 16 } else { 17 // This branch exists ONLY because the compiler cannot know 18 // whether Circle and Rectangle are "all of them" - as far as 19 // Shape is concerned, ANY class could implement it 20 throw new IllegalStateException("Unknown shape: " + shape); 21 } 22}
1// AFTER - a sealed interface, closed by declaration 2 3sealed interface Shape permits Circle, Rectangle {} 4 5record Circle(double radius) implements Shape {} 6record Rectangle(double width, double height) implements Shape {} 7 8// record Triangle(double base, double height) implements Shape {} 9// COMPILE ERROR - Triangle is not in Shape's permits clause 10 11double area(Shape shape) { 12 return switch (shape) { 13 case Circle(double radius) -> Math.PI * radius * radius; 14 case Rectangle(double width, double height) -> width * height; 15 // NO default - the compiler verified Circle and Rectangle 16 // are the ONLY possible Shape values 17 }; 18}

The else branch in the "before" version was not handling a real case - it was a safety net for a possibility the compiler could not rule out, because Shape placed no restriction on who could implement it. The "after" version removes that branch entirely, and the removal is not optional cleanup - the compiler requires the switch to cover exactly Circle and Rectangle, because Shape's declaration says those are the only two that exist. If a third shape is ever added to permits, every switch like this one that does not yet handle it becomes a compile error - turning "we forgot to handle the new case" from a bug report into a build failure, at every affected location, immediately.

Syntax

DECLARING A SEALED TYPE:
  sealed interface Shape permits Circle, Rectangle { }
  sealed class Vehicle permits Car, Truck { }

  - 'permits' lists every type allowed to directly extend/implement
    this one
  - 'permits' can be OMITTED if every permitted type is declared in
    the SAME source file - the compiler infers the list

EVERY DIRECT PERMITTED SUBTYPE MUST BE EXACTLY ONE OF:

  final        - the hierarchy ends here
    final class Car implements Vehicle { }

  sealed       - extendable, but only by its OWN permits list
    sealed interface Polygon extends Shape permits Triangle, Square { }

  non-sealed   - reopens extension from this point onward
    non-sealed class ElectricVehicle implements Vehicle { }

WHERE PERMITTED SUBTYPES MUST LIVE:
  - the same module as the sealed type, if modules are used, or
  - the same package, if not - typically the same source file when
    'permits' is omitted

Common Use Cases

A Closed Result Type With Exhaustive Switch

The most common shape for sealed types: a small, closed set of possible outcomes, each represented by a record, handled by a switch with no default.

1// File: PaymentResultDemo.java 2 3public class PaymentResultDemo { 4 5 sealed interface PaymentResult permits Success, Failure {} 6 7 record Success(String transactionId, double amount) implements PaymentResult {} 8 record Failure(String reason) implements PaymentResult {} 9 10 static String describe(PaymentResult result) { 11 return switch (result) { 12 case Success(String transactionId, double amount) -> 13 "Payment successful: " + transactionId + " for Rs." + amount; 14 case Failure(String reason) -> 15 "Payment failed: " + reason; 16 // No default needed - PaymentResult permits ONLY 17 // Success and Failure 18 }; 19 } 20 21 public static void main(String[] args) { 22 PaymentResult success = new Success("TXN-9001", 1499.0); 23 PaymentResult failure = new Failure("Insufficient balance"); 24 25 System.out.println(describe(success)); 26 System.out.println(describe(failure)); 27 } 28}
Output:
Payment successful: TXN-9001 for Rs.1499.0
Payment failed: Insufficient balance

Reopening a Branch With non-sealed

non-sealed is a deliberate, visible decision: this specific branch of an otherwise closed hierarchy is open to extension by anything, anywhere. The switch over the top-level sealed type stays exhaustive - it only needs to know about the permitted branch, not every possible subclass of that branch.

1// File: VehicleHierarchyDemo.java 2 3public class VehicleHierarchyDemo { 4 5 sealed interface Vehicle permits Car, Truck, ElectricVehicle { 6 String describe(); 7 } 8 9 // final - Car cannot be extended further 10 static final class Car implements Vehicle { 11 @Override 12 public String describe() { return "Car"; } 13 } 14 15 // final - Truck cannot be extended further 16 static final class Truck implements Vehicle { 17 @Override 18 public String describe() { return "Truck"; } 19 } 20 21 // non-sealed - intentionally REOPENS extension. Any class, anywhere, 22 // can extend ElectricVehicle - the closed guarantee deliberately 23 // ends here, by the original author's explicit choice. 24 static non-sealed class ElectricVehicle implements Vehicle { 25 @Override 26 public String describe() { return "ElectricVehicle"; } 27 } 28 29 // Allowed - ElectricVehicle is non-sealed, so this compiles even 30 // though ElectricScooter is NOT listed in Vehicle's permits clause 31 static class ElectricScooter extends ElectricVehicle { 32 @Override 33 public String describe() { return "ElectricScooter (extends ElectricVehicle)"; } 34 } 35 36 static String classify(Vehicle vehicle) { 37 return switch (vehicle) { 38 case Car c -> "Four-wheeler: " + c.describe(); 39 case Truck t -> "Heavy vehicle: " + t.describe(); 40 case ElectricVehicle e -> "Electric: " + e.describe(); 41 // Still exhaustive - ElectricScooter IS-A ElectricVehicle, so 42 // it matches THIS case. Vehicle's permits clause only lists 43 // Car, Truck, and ElectricVehicle - the compiler does not 44 // need (and cannot have) a separate case for ElectricScooter 45 }; 46 } 47 48 public static void main(String[] args) { 49 System.out.println(classify(new Car())); 50 System.out.println(classify(new Truck())); 51 System.out.println(classify(new ElectricVehicle())); 52 System.out.println(classify(new ElectricScooter())); 53 } 54}
Output:
Four-wheeler: Car
Heavy vehicle: Truck
Electric: ElectricVehicle
Electric: ElectricScooter (extends ElectricVehicle)

Multi-Level Sealed Hierarchies

A sealed type's permitted subtype can itself be sealed, with its own narrower permits list. A switch over the top-level type must still account for every concrete type reachable through that nested hierarchy - the compiler follows the chain all the way down.

1// File: ShapeHierarchyDemo.java 2 3public class ShapeHierarchyDemo { 4 5 sealed interface Shape permits Circle, Polygon {} 6 7 record Circle(double radius) implements Shape {} 8 9 // Polygon is ITSELF sealed - it further restricts who can implement it 10 sealed interface Polygon extends Shape permits Triangle, Square {} 11 12 record Triangle(double base, double height) implements Polygon {} 13 record Square(double side) implements Polygon {} 14 15 static double area(Shape shape) { 16 return switch (shape) { 17 case Circle(double radius) -> Math.PI * radius * radius; 18 case Triangle(double base, double height) -> 0.5 * base * height; 19 case Square(double side) -> side * side; 20 // Exhaustive across BOTH levels - Shape permits Circle and 21 // Polygon; Polygon permits Triangle and Square. The compiler 22 // verifies all THREE concrete shapes are handled here, even 23 // though only TWO names appear in Shape's own permits clause 24 }; 25 } 26 27 public static void main(String[] args) { 28 System.out.printf("Circle area : %.2f%n", area(new Circle(3.0))); 29 System.out.printf("Triangle area : %.2f%n", area(new Triangle(6.0, 4.0))); 30 System.out.printf("Square area : %.2f%n", area(new Square(5.0))); 31 } 32}
Output:
Circle area   : 28.27
Triangle area : 12.00
Square area   : 25.00

Real-World Example - Zerodha Order Execution Result

A trading platform's order execution result is a textbook closed set: an order is filled, partially filled, rejected, or still pending - exactly four possibilities, each carrying different data, and every part of the system that displays or acts on an order result needs to handle all four, with nothing left to a default guess.

1// File: OrderResult.java 2 3public sealed interface OrderResult permits Filled, PartiallyFilled, Rejected, Pending {} 4 5record Filled(String orderId, int quantity, double averagePrice) implements OrderResult {} 6 7record PartiallyFilled(String orderId, int filledQuantity, int remainingQuantity, double averagePrice) 8 implements OrderResult {} 9 10record Rejected(String orderId, String reason) implements OrderResult {} 11 12record Pending(String orderId) implements OrderResult {}
1// File: OrderResultService.java 2 3import java.util.List; 4 5public class OrderResultService { 6 7 public String describeResult(OrderResult result) { 8 return switch (result) { 9 case Filled(String orderId, int quantity, double avgPrice) -> 10 "Order " + orderId + " FILLED: " + quantity + " units @ Rs." + avgPrice; 11 12 case PartiallyFilled(String orderId, int filled, int remaining, double avgPrice) -> 13 "Order " + orderId + " PARTIALLY FILLED: " + filled + "/" + (filled + remaining) 14 + " units @ Rs." + avgPrice + " (remaining: " + remaining + ")"; 15 16 case Rejected(String orderId, String reason) -> 17 "Order " + orderId + " REJECTED: " + reason; 18 19 case Pending(String orderId) -> 20 "Order " + orderId + " is PENDING - awaiting exchange confirmation"; 21 }; 22 } 23 24 // A SECOND switch over the SAME sealed type - independently 25 // exhaustive, with completely different logic. Sealed types are 26 // designed for exactly this: many places can each handle "all of 27 // OrderResult" their own way, and each one is checked separately. 28 public boolean requiresFollowUp(OrderResult result) { 29 return switch (result) { 30 case Filled f -> false; 31 case PartiallyFilled p -> true; 32 case Rejected r -> true; 33 case Pending p -> true; 34 }; 35 } 36 37 public static void main(String[] args) { 38 OrderResultService service = new OrderResultService(); 39 40 List<OrderResult> results = List.of( 41 new Filled("ORD-1001", 50, 2415.50), 42 new PartiallyFilled("ORD-1002", 30, 20, 1899.75), 43 new Rejected("ORD-1003", "Insufficient margin"), 44 new Pending("ORD-1004") 45 ); 46 47 System.out.println("=== Order results ==="); 48 for (OrderResult result : results) { 49 System.out.println(" " + service.describeResult(result)); 50 } 51 52 System.out.println(); 53 System.out.println("=== Orders requiring follow-up ==="); 54 for (OrderResult result : results) { 55 if (service.requiresFollowUp(result)) { 56 System.out.println(" " + service.describeResult(result)); 57 } 58 } 59 } 60}
Output:
=== Order results ===
  Order ORD-1001 FILLED: 50 units @ Rs.2415.5
  Order ORD-1002 PARTIALLY FILLED: 30/50 units @ Rs.1899.75 (remaining: 20)
  Order ORD-1003 REJECTED: Insufficient margin
  Order ORD-1004 is PENDING - awaiting exchange confirmation

=== Orders requiring follow-up ===
  Order ORD-1002 PARTIALLY FILLED: 30/50 units @ Rs.1899.75 (remaining: 20)
  Order ORD-1003 REJECTED: Insufficient margin
  Order ORD-1004 is PENDING - awaiting exchange confirmation

describeResult uses record patterns to destructure each variant's fields directly in the case label; requiresFollowUp uses plain type patterns and asks a completely different question about the same four types. Both are exhaustive independently - if a fifth OrderResult variant is ever added to permits, both methods (and any other switch like them anywhere in the codebase) become compile errors until updated, which is precisely the point: a new order outcome cannot silently fall through either piece of logic unnoticed.

Best Practices

Reach for sealed types when a value is genuinely "one of a known, finite set of shapes" - not as a general-purpose replacement for interfaces. Order results, API response variants, AST/expression nodes, UI states, parsed-command types: these are closed by their very nature - there is no meaningful "fifth kind of OrderResult" waiting to be discovered by calling code. An interface meant for arbitrary, unforeseen implementations - a plugin contract, a strategy interface - should stay open.

Pair sealed interfaces with records for each variant. OrderResult permits Filled, PartiallyFilled, Rejected, Pending, with each variant a record, gives both a closed set of shapes AND value semantics for each shape's data - the combination this article has used throughout, and the one most discussions of "sealed types" in modern Java are really about.

Use non-sealed deliberately, and treat it as documentation. A non-sealed branch is a statement: "the closed-hierarchy guarantee intentionally stops here." It should be rare enough in a given hierarchy that, when it appears, a reader notices it and understands why - usually because that branch represents an extension point meant for code outside the module that owns the sealed type.

When adding a new permitted subtype breaks existing switches, that is the feature working - do not "fix" it with a default branch. Each compile error from an exhaustiveness check is the exact location that needs to handle the new case. Adding default -> /* do nothing */ to make the error go away defeats the entire reason for using a sealed type in the first place - it reintroduces the "silently ignored case" problem sealed types exist to eliminate.

Common Mistakes

Mistake 1 - A Permitted Subtype Missing final, sealed, or non-sealed

1sealed interface Shape permits Circle, Rectangle {} 2 3record Circle(double radius) implements Shape {} // records are implicitly final - fine 4 5// WRONG - 'Rectangle' is permitted by Shape, but does not declare 6// itself as final, sealed, or non-sealed. COMPILE ERROR: 7// "class Rectangle is not final, sealed, or non-sealed" 8class Rectangle implements Shape { 9 double width; 10 double height; 11} 12 13// CORRECT - every permitted subtype must declare what happens next. 14// A plain class (not a record) needs one of the three explicitly. 15final class RectangleFixed implements Shape { 16 double width; 17 double height; 18}

Mistake 2 - Placing a Permitted Subtype in a Different Package

1// File: shapes/Shape.java 2package shapes; 3 4// WRONG - permits names a class in a DIFFERENT package without a 5// module relationship that allows it. COMPILE ERROR along the lines 6// of: "class shapes.extra.Triangle is not allowed to extend sealed 7// class shapes.Shape because shapes.extra.Triangle is in a different 8// package/module" 9public sealed interface Shape permits shapes.Circle, shapes.extra.Triangle {} 10 11// CORRECT - keep permitted subtypes in the SAME package (or, with 12// modules, the same module) as the sealed type itself 13// File: shapes/Triangle.java 14package shapes; 15 16final class Triangle implements Shape { 17 double base; 18 double height; 19}

Mistake 3 - Adding a default to Silence an Exhaustiveness Error

1sealed interface OrderResult permits Filled, PartiallyFilled, Rejected, Pending {} 2// ... record declarations ... 3 4// A new variant is added to the permits clause: 5// sealed interface OrderResult permits Filled, PartiallyFilled, Rejected, Pending, Cancelled {} 6// record Cancelled(String orderId) implements OrderResult {} 7 8// WRONG - the switch below now fails to compile because it does not 9// cover Cancelled. Adding 'default' makes it compile again, but 10// SILENTLY drops every Cancelled order into whatever the default does 11String describe(OrderResult result) { 12 return switch (result) { 13 case Filled f -> "Filled"; 14 case PartiallyFilled p -> "Partially filled"; 15 case Rejected r -> "Rejected"; 16 case Pending p -> "Pending"; 17 default -> "Unknown"; // defeats the purpose of sealing OrderResult 18 }; 19} 20 21// CORRECT - add the missing case. This is the exhaustiveness check 22// doing exactly what it is for: finding every place Cancelled needs 23// to be handled, at compile time 24String describeFixed(OrderResult result) { 25 return switch (result) { 26 case Filled f -> "Filled"; 27 case PartiallyFilled p -> "Partially filled"; 28 case Rejected r -> "Rejected"; 29 case Pending p -> "Pending"; 30 case Cancelled c -> "Cancelled"; 31 }; 32}

Mistake 4 - Confusing sealed With final

1// WRONG ASSUMPTION - 'final' on Shape would mean NOTHING can ever 2// implement or extend it - but Shape is meant to have several 3// implementations (Circle, Rectangle). 'final' on an interface is 4// not even valid; on a class, it would prevent ALL subclassing, 5// not describe a closed SET of subclasses. 6final class Shape { } // cannot be implemented/extended AT ALL 7 8// CORRECT - 'sealed' with 'permits' describes a CLOSED SET of 9// allowed subtypes - "exactly these, and no others" - which is a 10// different statement from 'final's "no subtypes at all" 11sealed interface ShapeFixed permits Circle, Rectangle {} 12record Circle(double radius) implements ShapeFixed {} 13record Rectangle(double width, double height) implements ShapeFixed {} 14 15// 'final' is still the right choice for an individual VARIANT - it 16// says "this specific shape has no further subtypes," which is a 17// different statement, made at a different level of the hierarchy

Interview Questions

Q1. What is a sealed class or interface in Java, and what problem does it solve?

A sealed class or interface, finalized in Java 17, declares - via the sealed modifier and a permits clause - the complete, closed set of types allowed to directly extend or implement it. It solves the problem of representing "one of a few specific things, and nothing else" as a type: before sealed types, any interface or non-final class was open to implementation by any class anywhere, so code handling "every possible subtype" always needed a fallback branch for cases the compiler could not rule out. A sealed type lets the compiler verify that a fixed list is genuinely complete, both by preventing unlisted subtypes from existing and by checking that code handling the type's subtypes covers all of them.

Q2. What are the three options for a direct subtype of a sealed type, and what does each mean?

Every type named in a sealed type's permits clause must itself be exactly one of final, sealed, or non-sealed. final means the hierarchy ends at that type - it cannot be extended further. sealed means that type can be extended, but only by types in ITS OWN permits clause - creating a multi-level closed hierarchy. non-sealed means the closed-hierarchy guarantee deliberately stops at that type - from there onward, any class anywhere can extend it normally, as if it were an ordinary open type. Records satisfy the final requirement automatically, since records are implicitly final.

Q3. When can the permits clause be omitted, and why?

permits can be omitted if every type that implements or extends the sealed type is declared in the same source file as the sealed type itself. In that case, the compiler scans the rest of the file, finds every type that names the sealed type in an implements or extends clause, and treats that as the permitted list automatically. This is common for small, self-contained hierarchies - a sealed interface with two or three record implementations, all in one file - where writing out permits would just repeat information already visible a few lines later.

Q4. How does a sealed type enable exhaustive switch, and what Java version is required for that?

Because a sealed type's complete set of permitted subtypes is known to the compiler, a switch using pattern matching (type patterns like case Circle c ->, or record patterns like case Circle(double radius) ->) can be checked for exhaustiveness - the compiler verifies every permitted subtype (including, recursively, the permitted subtypes of any permitted subtype that is itself sealed) is covered by some case, and a default branch becomes unnecessary. Sealed type declarations themselves are available from Java 17, but pattern matching for switch - the feature that makes this exhaustiveness checking possible - was finalized in Java 21, after preview status starting in 17. On Java 17 through 20, the same switch-based exhaustiveness requires enabling the relevant preview feature.

Q5. Can a sealed interface have implementations in a different package or module?

Only if the sealed type and the permitted subtype are in the same module (when modules are used) - in that case, they can be in different packages within that module. Without modules, every permitted subtype must be in the same package as the sealed type. This restriction exists because the closed-set guarantee would be meaningless if any code, anywhere, with access to the sealed type could add a new implementation from an unrelated package - the compiler needs to see and verify the entire permitted set, which it can only do within these boundaries.

Q6. How do sealed types and records work together, and why is this combination significant?

A sealed interface with record implementations gives a value two guarantees at once: the set of possible "shapes" the value can take is closed and verified by the compiler (from sealed), and each shape's data has value-based equality, an automatically generated toString(), and immutability (from record). Combined with pattern matching for switch and record patterns, this lets code destructure and exhaustively handle "one of N specific data shapes" - Filled, PartiallyFilled, Rejected, Pending, in the example above - with no default branch, no manual instanceof casts, and a compile error at every affected location if a new shape is ever added. This combination is often referred to as Java's approach to algebraic data types, familiar from functional languages, expressed using features the language already had individually.

FAQs

Can a sealed class be extended by an anonymous class?

No. An anonymous class's supertype would not appear in the sealed type's permits clause, so it would violate the closed-set guarantee - the compiler rejects this. If a one-off implementation is needed, it must be one of the types already listed in permits, used directly (records, in particular, are very commonly used this way - each permitted "shape" is just a record, with no need for one-off implementations at all).

Can an enum be a permitted subtype of a sealed interface?

Yes. An enum can implement an interface, including a sealed one - declaring enum Status implements StatusLike where StatusLike is a sealed interface permitting Status among its implementations is valid. Since an enum's set of constants is itself closed, combining an enum with a sealed interface is sometimes used when some "shapes" in a closed set are simple, valueless constants (a good fit for enum constants) while others carry data (a good fit for records).

What happens if a switch over a sealed type doesn't cover all permitted subtypes?

With pattern matching for switch (Java 21), this is a compile error - the switch expression or statement is rejected as non-exhaustive, naming which permitted subtype(s) are not covered. This is the entire point of combining switch with a sealed type: an incomplete switch over a sealed type's subtypes cannot compile, whereas an incomplete switch (or if-else chain) over an ordinary open type compiles fine and simply produces no result - or a default - for unhandled cases at runtime.

Can a sealed class have non-sealed, sealed, AND final subtypes all at once?

Yes - each permitted subtype chooses independently. sealed interface Vehicle permits Car, Truck, ElectricVehicle could have Car and Truck as final while ElectricVehicle is non-sealed, exactly as in this article's example, or ElectricVehicle could instead be sealed with its own further-restricted permits. There is no requirement that all permitted subtypes use the same option.

Is sealed the same as final?

No, and the difference is the entire point of the feature. final on a class means it cannot be extended or implemented by anything, ever - there is no hierarchy below it at all. sealed (with permits) on a class or interface means it CAN be extended or implemented, but only by a specific, closed, named set of types - there IS a hierarchy below it, and that hierarchy's membership is fixed and known. A sealed interface with several record implementations has a real hierarchy of several types; a final class has none.

Can a sealed interface extend a non-sealed interface, or vice versa?

A sealed interface can extend any interface, sealed or not - sealed interface Polygon extends Shape permits Triangle, Square extends Shape, regardless of whether Shape itself is sealed. A non-sealed interface extending a sealed interface is more unusual: if Shape is sealed and permits Polygon, then Polygon itself must be final, sealed, or non-sealed - if Polygon chooses non-sealed, it reopens extension from that point, exactly as non-sealed does for classes, while still being one of Shape's explicitly permitted types.

Summary

A sealed type turns "these are all the kinds of X that exist" from a comment or a naming convention into something the compiler verifies - both that nothing outside the permitted list can claim to be an X, and, with pattern matching for switch from Java 21, that code handling "every kind of X" genuinely does. Every permitted subtype must say, explicitly, what happens next: final ends the hierarchy there, sealed continues it under a new, narrower closed list, and non-sealed deliberately reopens it.

The combination this article keeps returning to - a sealed interface, one record per permitted shape, and an exhaustive switch with record patterns - is worth recognizing on sight. It is the modern Java answer to "this value is one of a few specific things, each with its own data, and every place that handles it needs to handle all of them."

The practical habit worth carrying forward: when a new variant needs to be added to a sealed type's permits, the compile errors that follow at every now-incomplete switch are not a problem to work around - they are the complete, precise list of every place that needs to learn about the new variant, found before the code ever runs.

What to Read Next