Sealed types let a parent class or interface name the exact set of subtypes that are allowed to extend or implement it. The syntax basics, including the permits clause, the location rule, and the three modifiers a permitted subtype must pick, were covered earlier. This lesson is the modern-Java view: how sealed pairs with records to model closed domains, how it makes pattern-matching switches exhaustive, how to model state machines with it, and how it shows up in real API design.
Three things to recall for the rest of this lesson.
First, a sealed type declares the closed list of its direct subtypes with permits. Anyone not on the list can't extend it. Second, every direct subtype must pick one of three modifiers: final (the chain stops), sealed (the chain continues with another closed list), or non-sealed (the chain reopens to any extender). Third, the parent and all permitted subtypes must live in the same package, or in the same Java module if your project is modular.
The minimum skeleton showing all three modifiers:
FullRefund is final: the chain stops. PartialRefund is sealed and continues with a smaller permits list of its own. StoreCreditRefund is non-sealed, so anyone, including a separate gifting module, can extend it. From here on, the lesson is about what you can build with this vocabulary.
A common pattern in modern Java code is a sealed interface plus records as the permitted subtypes. This pattern has a name borrowed from functional languages: algebraic data type, often shortened to ADT. The "sum type" form means "a value of this type is exactly one of these variants, and the list is closed." Java doesn't use the term in the spec, but the pattern is the same thing.
The combination is powerful for three reasons. The sealed interface gives you a closed list of variants. Records make each variant immutable and give you value-based equals, hashCode, and toString automatically. Together, they make exhaustive switch possible, which is the payoff.
A Payment type modeled this way:
Look at the shape. Three records, one sealed interface, one switch. Each record carries the data specific to that variant: a card has a card number and an expiry, PayPal has an email, cash has nothing extra. The switch handles each case once, and the compiler accepts it as exhaustive because the sealed declaration tells it the list of variants is complete.
The hierarchy looks like this:
The diagram makes the "closed list" property visible at a glance. The parent declares its variants on one line. Each variant is a record carrying its own data. Nothing else can implement Payment from anywhere in the codebase.
Records are reference types, so each variant is a heap allocation. For high-throughput code (millions of Payment objects per second), that matters, but for typical request/response code the cost is negligible compared to JSON parsing or database I/O.
This pattern took off in modern Java because it gives you what enums almost give you, but without the constraint that every case has identical fields. Records are covered fully in lesson 06 of this section. Here we use them as the building blocks for closed-variant types.
When you add a new variant to a sealed type, every exhaustive switch over that type stops compiling until you handle the new case. That's the central trade. It's strictly more work than an open hierarchy, where adding a subtype silently flows through a default branch. It's also strictly safer: the compiler tells you exactly where the new variant needs handling.
An example. Suppose the e-commerce team adds a fourth payment method, BankTransfer. They update the sealed interface but forget the switch:
The compiler refuses to build this with a message like:
The compiler points at the switch that's now incomplete, and the fix is mechanical: add the missing case.
Compare this to the open-hierarchy approach. With a regular interface, adding BankTransfer would let the new class compile fine, the switch would compile fine, and BankTransfer payments would hit the default branch (or whatever the fallback was) without warning. That kind of failure is much more expensive to find because it surfaces at runtime, often in production, often as a wrong value rather than an exception.
A small but important detail: the exhaustiveness check is a property of switch expressions (the arrow form). Classic switch statements over sealed types are also exhaustive in modern Java when used with pattern matching, but the older case X: colon form with break doesn't enforce exhaustiveness in the same way.
State machines are one of the cleanest fits for a sealed interface plus records. An order moves through Placed, Confirmed, Shipped, Delivered, and Cancelled. Each state has different data attached: a placed order knows when it was placed, a shipped order knows its tracking number, a delivered order knows when it arrived, a cancelled order knows why. Trying to fit that into a single enum forces every state to share one shape.
The model:
First, the sealed interface declares one common method, orderId(), that every state must provide. Records satisfy that automatically when one of their components is named orderId. That's how you put shared data on the parent without forcing every state into one shape. Second, each state record carries only the data meaningful for that state. There's no tracking field on Placed, no placedAt on Cancelled. Nothing sits as null waiting to cause errors.
The transitions look like this:
The diagram is the business rule. The sealed interface is the type-level encoding of the same rule, minus the transitions themselves (Java doesn't restrict which transitions are valid; that's enforced by a service layer or a state machine library). What the sealed interface gives you is the closed set of legal states, where each one carries only the data that makes sense for it.
Sealed interfaces don't replace enums. They replace them only in cases where the variants have different shapes. Compare:
| Property | enum OrderStatus | sealed interface OrderState |
|---|---|---|
| Same fields for every case | Required | Not required, each variant declares its own |
Compile-time constants you can switch on | Yes | Yes (via pattern matching) |
| Exhaustive switch | Yes | Yes |
| Carries case-specific data | No, all enum constants share fields | Yes, each record carries its own |
values(), valueOf(), ordinal() | Yes | No (would need to write them) |
| Allocation cost | Singletons, no per-use allocation | Each new state allocates a record |
When to use which?
Use an enum when:
Priority.LOW, Priority.MEDIUM, Priority.HIGH. There's nothing extra to attach.values(), ordinal(), or EnumSet.Use a sealed interface with records when:
Shipped has a tracking number, Cancelled has a reason, Placed has a timestamp.Shipped(1001, TRK-91022, 2026-05-15) records are equal").A useful check: when writing if (status == OrderStatus.SHIPPED) { /* read trackingNumber that lives somewhere else */ }, the enum is doing the wrong job. The state and its data belong together. A sealed interface plus records puts them together.
Sealed types fit naturally into API design when a method has more than one normal outcome. The classic example is "look up a customer by email." There are three possible outcomes: the customer exists, the customer doesn't exist, or the lookup failed for a system reason. Modeling this with an exception conflates "expected absence" with "something went wrong," which makes callers handle both with try/catch.
A sealed result type makes the outcomes explicit. A minimal version:
The caller is forced to think about all three cases because the switch is exhaustive. There's no "I forgot to handle the missing case" failure mode. There's no "I caught the exception and discarded it" failure mode either, because nothing throws.
This is a quick example, not a full pitch for a Try-monad style or Rust-style Result. Java doesn't have first-class flat-mapping syntax for chaining these, so deeply nested Result pipelines get noisier than the equivalent in Scala or Kotlin. The pattern still pulls its weight in two places: at API boundaries where callers should handle every outcome explicitly, and in domain code where the failure modes are part of the business logic, not a system error.
Don't use this pattern for exceptional failures, like out-of-memory, broken network connections, or programming errors. Those belong in exceptions, which propagate up the stack until somebody decides what to do with them. A sealed Result is for expected outcomes that aren't success.
The permits clause is the line where you write out the closed list. It's required when permitted subtypes live in different source files in the same package. It's optional when all the permitted subtypes are declared in the same compilation unit (the same .java file) as the parent. The compiler infers the list in that case.
In one file, you can leave permits off:
Once the variants move to their own files, the permits clause becomes mandatory. Even if both StandardShipping.java and ExpressShipping.java sit next to Shipping.java in the same package, the compiler needs the parent to spell out the list.
A practical guideline: for small closed families (three or four variants), put everything in one file. The whole hierarchy is visible at a glance and there's no permits clause to maintain. For larger families, split into one file per variant and keep the explicit permits clause on the parent. The compiler enforces a one-line summary of the variant list either way.
Don't scatter the variants across many packages just to "organize" them. Even where Java modules technically allow it, doing so makes the closed-list property harder for humans to verify. Keep all the variants under the same conceptual roof.
Most modern uses of sealing favor a sealed interface, not a sealed class. The reasons come down to how the variants are most naturally represented.
Use a sealed interface when:
Use a sealed class when:
non-sealed) and need to inherit machinery from the parent.What's wrong with this code?
Fix:
SmsNotification is listed in Notification's permits clause but doesn't implement Notification. The compiler reports something like class is not a permitted subclass of sealed class. The fix is to make SmsNotification implement Notification. A record works, and so does a final class SmsNotification implements Notification. Being in the permits list is a claim that the listed class is a subtype; if it isn't a subtype, the compiler rejects it.
Sealed classes (not interfaces) also constrain how the variants can declare themselves. Because a class can extend only one parent, a variant of a sealed class can't simultaneously be a variant of another sealed class. With sealed interfaces, a variant can implement multiple sealed interfaces (it can be in two different closed families at once). That's another reason interfaces tend to win in practice: composition is easier.
Sealing is a strong constraint. It cuts off open extension in exchange for closed-list guarantees and exhaustive switch. Sometimes that trade is wrong.
Don't use sealed when:
Plugin interface that third-party libraries implement should stay open. Sealing it would mean every new plugin requires you to ship a new release.The clearest signal that sealing is right is when you'd otherwise add a default branch to every switch that handles the type, and that default branch would be wrong for any future case. Sealed types replace that fragile default with a compile-time check.
Sealed types don't have a runtime cost beyond the regular cost of method dispatch. The compiler does its checking at compile time and emits the same kind of class file it would for an unsealed hierarchy, with one extra PermittedSubclasses attribute that records the allowed list.
Sealed types and modules interact through the location rule. In a non-modular project, the parent and every permitted subtype must share a package. In a project that uses Java modules, they can live in different packages as long as those packages are in the same named module.
Sealing only makes sense if the compiler can see the entire family of subtypes at once. A module boundary that the compiler can verify is enough to satisfy that. A package boundary in an unnamed module isn't, because anything outside the package could create a subtype the parent doesn't know about. For most code, the recommendation is the same regardless of modules: keep the parent and the variants in one place, and don't spread a sealed family across compilation units that don't naturally belong together.
10 quizzes