How to create sealed classes in Java
Java 17 finalised sealed classes (JEP 409), a language feature that gives you fine‑grained control over inheritance.
With sealed classes, you can declare a class or interface and explicitly list which types are allowed to extend or implement it.
This fills the gap between final (no subclasses) and completely open inheritance (any subclass allowed).
The Inheritance Dilemma
Before sealed classes, Java developers faced a choice:
finalclasses – too restrictive; you cannot extend them at all.- Open classes – anyone can extend them, which makes it hard to reason about a fixed set of subtypes.
Sealed classes introduce a third option: “only these specific classes may extend or implement this type.” This is invaluable when modelling a closed domain, such as:
- Shapes in a geometry library (
Circle,Rectangle,Triangle) - Payment statuses (
Success,Failure,Pending) - AST nodes in a compiler (
IfNode,WhileNode,AssignmentNode) - JSON value types (
JsonObject,JsonArray,JsonString, etc.)
Key Keywords and Their Roles
Understanding the interplay of sealed, permits, final, and non-sealed is essential:
| Keyword | Used On | Meaning |
|---|---|---|
sealed | Class or interface | Restricts which types may extend/implement it; must be followed by permits (unless subclasses are nested). |
permits | After sealed | Lists the permitted direct subclasses or implementors. |
final | Subclass | Cannot be extended further – the hierarchy ends here. |
sealed | Subclass | Is itself sealed and can have its own permitted subclasses (nested sealed hierarchy). |
non-sealed | Subclass | Re‑opens the subclass for arbitrary extension – the sealed constraint stops here. |
Basic Example: Sealed Class with permits
Let's define a sealed Shape class that allows only three specific subclasses:
public abstract sealed class Shape permits Circle, Rectangle, Triangle {
public abstract double area();
}
public final class Circle extends Shape {
private final double radius;
public Circle(double radius) { this.radius = radius; }
@Override
public double area() { return Math.PI * radius * radius; }
}
public final class Rectangle extends Shape {
private final double width, height;
public Rectangle(double width, double height) { this.width = width; this.height = height; }
@Override
public double area() { return width * height; }
}
public final class Triangle extends Shape {
private final double base, height;
public Triangle(double base, double height) { this.base = base; this.height = height; }
@Override
public double area() { return 0.5 * base * height; }
}
Any attempt to create a fourth subclass (e.g., Hexagon) will be rejected by the compiler – the hierarchy is closed.
Omitting permits with Nested Subclasses
If all permitted subclasses are defined as nested classes inside the sealed class, you can omit the permits clause:
public abstract sealed class Operation {
public abstract int apply(int a, int b);
public static final class Add extends Operation {
@Override public int apply(int a, int b) { return a + b; }
}
public static final class Sub extends Operation {
@Override public int apply(int a, int b) { return a - b; }
}
public static final class Mul extends Operation {
@Override public int apply(int a, int b) { return a * b; }
}
}
This keeps the code self‑contained and clearly communicates that these are the only operations.
Sealed Interfaces and Records
Sealed interfaces work equally well and pair beautifully with records (which are implicitly final):
public sealed interface PaymentResult permits Success, Failure, Pending { }
public record Success(String transactionId) implements PaymentResult { }
public record Failure(String reason) implements PaymentResult { }
public record Pending(String reference) implements PaymentResult { }
Records are a natural choice here because each variant holds different data, and they are inherently immutable.
Pattern Matching with switch (Java 21+)
The real power of sealed classes shines when combined with pattern matching in switch expressions.
Because the compiler knows all permitted subtypes, it can verify exhaustiveness – no default case is needed:
double describeArea(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> 0.5 * t.base() * t.height();
// No default – compiler ensures all cases are covered
};
}
If you later add a new permitted subclass (say Hexagon) to Shape, every switch over Shape that lacks a Hexagon case will become a compile‑time error – catching the gap immediately.
Reopening a Branch with non-sealed
Sometimes you want to allow arbitrary extension for some part of the hierarchy. Mark a permitted subclass as non-sealed to reopen it:
public abstract sealed class Vehicle permits Car, Truck, SpecialVehicle { }
public final class Car extends Vehicle { }
public final class Truck extends Vehicle { }
public non-sealed class SpecialVehicle extends Vehicle { }
// Now anyone can extend SpecialVehicle:
public class Ambulance extends SpecialVehicle { }
public class FireTruck extends SpecialVehicle { }
This gives you a hybrid model: a closed top‑level family, with an open branch for extensibility where needed.
Important Rules and Constraints
- The sealed class and all its permitted subclasses must be in the same package or in the same module.
- Every permitted subclass must directly extend the sealed class (not a subclass of it).
- Each permitted subclass must be marked
final,sealed, ornon-sealed. - If the sealed class is an interface, the implementors must be
final,sealed, ornon-sealedclasses (records are implicitlyfinal). - You cannot use a sealed class with
enum– enums are already effectively a closed set of constants.
When to Use Sealed Classes vs Enums
| Feature | Sealed Classes | Enums |
|---|---|---|
| Number of instances | Many (unlimited) – each subclass can have many objects | Fixed set of singleton constants |
| State | Each subclass can have its own fields and behaviour | Enums can have fields, but they are effectively singletons |
| Pattern matching | Excellent – can deconstruct and handle different shapes | Limited to constant matching (switch on enum constants) |
| Use case | Modelling complex, state‑ful variants (e.g., AST nodes, JSON types) | Modelling fixed sets of named values (e.g., days of the week, status codes) |
Real‑World Use Cases
1. State Machines
public sealed interface State permits Idle, Running, Paused, Stopped { }
public record Idle() implements State { }
public record Running(long startTime) implements State { }
public record Paused(long elapsed) implements State { }
public record Stopped() implements State { }
2. Abstract Syntax Tree Nodes
public sealed interface Expr permits Const, Add, Mul, Var { }
public record Const(int value) implements Expr { }
public record Add(Expr left, Expr right) implements Expr { }
public record Mul(Expr left, Expr right) implements Expr { }
public record Var(String name) implements Expr { }
3. JSON Data Types
public sealed interface JsonValue permits JsonObject, JsonArray, JsonString, JsonNumber, JsonBoolean, JsonNull { }
public record JsonObject(Map<String, JsonValue> members) implements JsonValue { }
public record JsonArray(List<JsonValue> elements) implements JsonValue { }
public record JsonString(String value) implements JsonValue { }
// ... and so on
4. Payment Processing Results
We already saw the PaymentResult example – it's a perfect fit for sealed interfaces with records.
Best Practices
- Prefer
sealedinterfaces with records for data‑oriented hierarchies – they are concise and immutable. - Use
sealedclasses when you need shared state or behaviour that cannot be captured by records (e.g., mutable fields, helper methods). - Design for extensibility – if you anticipate that third‑party developers will need to extend a branch, mark that branch
non-sealed. - Combine with pattern matching to write safe, readable, and maintainable code that the compiler checks for completeness.
- Keep the permitted list small – a sealed hierarchy with more than 5–7 subtypes may indicate a design that could be simplified.
Summary
Sealed classes give you a powerful tool to declare closed type hierarchies while retaining the flexibility of object‑oriented design.
They turn runtime errors into compile‑time checks, especially when used with pattern matching.
Mark your root type with sealed, list your permitted subtypes, and choose final, sealed, or non-sealed for each one – depending on how open that branch should be.
Whether you are modelling a domain, building an interpreter, or handling responses from an external API, sealed classes help you express your intent clearly and let the compiler enforce your design decisions. Start using them today – they are available in Java 17 and later.
Happy coding!
Java sealed and non-sealed classes
Java 17 introduced sealed classes, giving you fine‑grained control over inheritance. When you declare a permitted subclass, you must mark it with one of three modifiers: final, sealed, or non-sealed.
The non-sealed modifier is the most nuanced – it deliberately reopens a branch of an otherwise closed hierarchy, allowing arbitrary further extension.
The Three Permitted Subclass Options
Every permitted subclass of a sealed class or interface must explicitly choose one of these three:
| Modifier | Meaning | Further Subclassing? | Use Case |
|---|---|---|---|
final |
This is the last class in this branch – no further extension. | ❌ No – compile error if anyone tries | Leaf types that complete the hierarchy. |
sealed |
This subclass is itself sealed with its own permits list. |
✅ Only by the explicitly listed types | Nested closed hierarchies. |
non-sealed |
This subclass is open for unlimited further extension. | ✅ Yes – anyone can extend it | Extension points for external code. |
Important: Omitting all three modifiers on a permitted subclass is a compile error – you must choose one.
non-sealed in Action
Consider a sealed hierarchy for Tesla vehicles – we want to lock down the primary variants, but allow one branch to be extended by third‑party developers:
// Sealed parent – only three permitted subclasses
public abstract sealed class Tesla permits Model3, ModelS, TeslaSUV {
public abstract int maxRangeKm();
}
// final – nobody can extend Model3 further
public final class Model3 extends Tesla {
@Override public int maxRangeKm() { return 576; }
}
// non-sealed – anyone may create custom subclasses of ModelS
public non-sealed class ModelS extends Tesla {
@Override public int maxRangeKm() { return 652; }
}
// non-sealed – TeslaSUV is open for extension too
public non-sealed class TeslaSUV extends Tesla {
@Override public int maxRangeKm() { return 560; }
}
// Third‑party or test code can now freely extend ModelS
public class LimitedEditionModelS extends ModelS {
@Override public int maxRangeKm() { return 700; }
}
Model3 is locked – nobody can extend it. ModelS and TeslaSUV are open branches – any code in any package can subclass them freely.
Why Would You Choose non-sealed?
You use non-sealed when the parent type needs a closed, known set of primary variants, but one or more of those variants is intentionally designed as an extension point for external code:
- Plugin / extension architectures – the core module defines sealed variants for its own use, but one
non-sealedvariant is the “custom plugin” slot. - Library APIs – you want to lock the primary type hierarchy while still letting users subclass a base implementation class.
- Gradual migration – converting an open hierarchy to sealed incrementally;
non-sealedis a temporary marker that lets existing subclasses continue to work while you tighten the hierarchy over time. - Testing – allowing test doubles to extend a
non-sealedsubclass without modifying production code.
Sealed Interface with non-sealed Implementation
The same principle applies to sealed interfaces – a non-sealed implementor opens the door for arbitrary implementations:
public sealed interface Notification permits EmailNotification, SmsNotification, CustomNotification {}
public record EmailNotification(String to, String subject) implements Notification {}
public record SmsNotification(String phone, String text) implements Notification {}
// non-sealed – third‑party code can create custom notification types
public non-sealed class CustomNotification implements Notification {
private final String channel;
private final String payload;
public CustomNotification(String channel, String payload) {
this.channel = channel;
this.payload = payload;
}
}
// Library users can freely extend CustomNotification
public class SlackNotification extends CustomNotification {
public SlackNotification(String channel, String message) {
super("slack", message);
}
}
Notice that CustomNotification is a class – records are implicitly final, so you need a regular class to be non-sealed and extensible.
Effect on Pattern Matching and Exhaustiveness
Pattern matching in switch with sealed types normally allows the compiler to verify exhaustiveness – it knows all permitted subtypes. A non-sealed subclass does not break exhaustiveness if the switch covers the supertype itself:
String describe(Tesla t) {
return switch (t) {
case Model3 m -> "Model 3, range " + m.maxRangeKm();
case ModelS s -> "Model S, range " + s.maxRangeKm();
case TeslaSUV u -> "Tesla SUV, range " + u.maxRangeKm();
// No default needed – still exhaustive because all permitted
// subtypes are covered. Any subclass of ModelS is also a ModelS.
};
}
The switch above remains exhaustive because a case ModelS s matches any ModelS or its subclasses.
However, you lose the ability to distinguish between ModelS and its custom subclasses in the pattern – you cannot pattern‑match on unknown types. If you need to handle custom subclasses differently, you must add a default case:
String describe(Tesla t) {
return switch (t) {
case Model3 m -> "Model 3, range " + m.maxRangeKm();
case ModelS s -> "Model S, range " + s.maxRangeKm();
case TeslaSUV u -> "Tesla SUV, range " + u.maxRangeKm();
default -> "Custom Tesla variant";
};
}
This is a conscious trade‑off: non-sealed gives flexibility at the cost of some exhaustiveness guarantees.
Gradual Migration: From Open to Sealed
Suppose you have an existing open hierarchy (no sealed parent) and you want to migrate to sealed to gain control. non-sealed is your bridge:
// Existing class hierarchy, now sealed
public abstract sealed class Shape permits Circle, Rectangle, CustomShape {
public abstract double area();
}
public final class Circle extends Shape {
private final double radius;
public Circle(double r) { radius = r; }
@Override public double area() { return Math.PI * radius * radius; }
}
public final class Rectangle extends Shape {
private final double w, h;
public Rectangle(double w, double h) { this.w = w; this.h = h; }
@Override public double area() { return w * h; }
}
// New: allow existing or future custom shapes
public non-sealed class CustomShape extends Shape {
private final double area;
public CustomShape(double area) { this.area = area; }
@Override public double area() { return area; }
}
// Now external users can still extend CustomShape,
// but they cannot extend Shape directly.
public class Triangle extends CustomShape {
private final double base, height;
public Triangle(double b, double h) {
super(0.5 * b * h);
this.base = b; this.height = h;
}
// CustomShape already provided the area; we keep consistency.
}
This approach lets you gradually tighten the hierarchy while keeping backward compatibility for existing subclasses.
When to Choose Each Modifier – A Decision Guide
| Scenario | Recommended Modifier | Reason |
|---|---|---|
| The subtype is a leaf – no further extension should ever occur. | final |
Locks the branch completely. |
| The subtype itself has a known, closed set of sub‑variants. | sealed |
Creates a nested sealed hierarchy. |
| The subtype is designed as an extension point for external code. | non-sealed |
Opens the branch for arbitrary extension. |
| You are migrating an existing open hierarchy to sealed. | non-sealed (temporarily) |
Maintains backward compatibility while you tighten the rest. |
| You need a default implementation that can be overridden by users. | non-sealed |
Allows user subclasses to override behaviour. |
Best Practices
- Prefer
finalfor most leaf types – it makes the hierarchy predictable and improves pattern‑matching exhaustiveness. - Use
non-sealedsparingly – it defeats the primary benefit of sealed types (controlled inheritance) for that branch. Make it clear in your documentation that the branch is an extension point. - Consider replacing
non-sealedwith a separate sealed interface if you can anticipate all possible extensions in advance. - For library APIs, use
non-sealedonly when you are confident that external subclasses are necessary and you are willing to support them. - Combine with pattern matching – but remember that a
defaultcase may be needed to handle custom subclasses ofnon-sealedtypes. - Document extension points – explicitly state which subclasses are
non-sealedand what contract they must fulfil.
Common Pitfalls
- Forgetting the modifier – every permitted subclass must declare one of
final,sealed, ornon-sealed. Omitting it causes a compile error. - Overusing
non-sealed– if you mark every permitted subclass asnon-sealed, the sealed class effectively becomes open – you might as well not use sealed at all. - Losing exhaustiveness – when you rely on exhaustive switches, remember that
non-sealedbranches require adefaultif you need to handle unknown subtypes separately. - Incompatibility with records – records are implicitly
final, so they cannot benon-sealed. Use regular classes if you need an extensible branch.
Quick Reference Card
| Modifier | Symbol | Effect | Pattern Matching |
|---|---|---|---|
final | 🔒 | No further subclasses | Exact match, fully exhaustive |
sealed | 🔐 | Restricted sub‑hierarchy | Exact match for declared sub‑types |
non-sealed | 🔓 | Open for arbitrary extension | Matches the supertype, may need default |
Summary
The non-sealed modifier reopens one branch of a sealed hierarchy for arbitrary extension. It is a deliberate design choice that balances the need for a closed, known set of primary variants with the flexibility of an extension point for external code.
Choose final when you want to lock a branch forever, sealed when you want a nested closed sub‑hierarchy, and non-sealed when a permitted subclass is itself designed to be extended by external code. Use it sparingly and document its purpose clearly.
Remember: sealed classes are about control – and non-sealed is a controlled way to give it up where it makes sense.
Happy coding!