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:

ModifierMeaningFurther 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-sealed variant 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-sealed is 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-sealed subclass 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

ScenarioRecommended ModifierReason
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 final for most leaf types – it makes the hierarchy predictable and improves pattern‑matching exhaustiveness.
  • Use non-sealed sparingly – 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-sealed with a separate sealed interface if you can anticipate all possible extensions in advance.
  • For library APIs, use non-sealed only when you are confident that external subclasses are necessary and you are willing to support them.
  • Combine with pattern matching – but remember that a default case may be needed to handle custom subclasses of non-sealed types.
  • Document extension points – explicitly state which subclasses are non-sealed and what contract they must fulfil.

Common Pitfalls

  • Forgetting the modifier – every permitted subclass must declare one of final, sealed, or non-sealed. Omitting it causes a compile error.
  • Overusing non-sealed – if you mark every permitted subclass as non-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-sealed branches require a default if you need to handle unknown subtypes separately.
  • Incompatibility with records – records are implicitly final, so they cannot be non-sealed. Use regular classes if you need an extensible branch.

Quick Reference Card

ModifierSymbolEffectPattern Matching
final🔒No further subclassesExact match, fully exhaustive
sealed🔐Restricted sub‑hierarchyExact match for declared sub‑types
non-sealed🔓Open for arbitrary extensionMatches 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!