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!