ByteScrollGet the app
☰ Topics
sealed-classes7 / 200‹›
JAVA / MODERN-JAVA3 minute read

Sealed classes — what problem do they solve?

Medium

A sealed class or interface lists exactly which types may extend it. You keep a public type but a closed set of subtypes, so the compiler can check a switch covers them all. Every subtype states whether the hierarchy stays closed or opens again (non-sealed). Java 17+.

How it works

  1. Declare the set. sealed interface Payment permits Card, Wallet, BankTransfer. Any other class that tries to implement Payment fails to compile.
  2. Each subtype picks a modifier.
    • final: nothing below it (records and enums are already final).
    • sealed: closed again, with its own permits list.
    • non-sealed: open to anyone from here down.
  3. Location rule. The sealed type and every class it lists have to live together: one module, or one package when the code isn't modular. When they all sit in one source file you can drop the permits clause and the compiler infers it.
  4. Why it matters.
    • Before Java 17 a type was either open to everyone or final. Package-private constructors were a hack that also hid the type's purpose.
    • Domain models often are closed: a payment is one of three kinds, never a fourth from a plugin.
    • The compiler and pattern-matching switch can now prove a switch is exhaustive, with no default.
  5. Runtime too. The JVM enforces the list, and Class.isSealed() / getPermittedSubclasses() expose it to reflection.

A variant where one branch is sealed again and another is reopened:

compile errorsealedinterfacePaymentrecord Card(final)record Wallet(final)sealed classBankTransferfinal classSepanon-sealedclass Achanyone mayextend Achclass CryptoimplementsPayment
compile errorsealedinterfacePaymentrecord Card(final)record Wallet(final)sealed classBankTransferfinal classSepanon-sealedclass Achanyone mayextend Achclass CryptoimplementsPayment

Example

Example.javaJava
sealed interface Payment permits Card, Wallet, BankTransfer {}
record Card(String last4) implements Payment {}
record Wallet(String provider) implements Payment {}
record BankTransfer(String iban) implements Payment {}

static double feePercent(Payment p) {
    return switch (p) {           // exhaustive: no default needed
        case Card c -> 2.9;
        case Wallet w -> 2.5;
        case BankTransfer b -> 0.8;
    };
}

Add record Voucher(String code) implements Payment {} and update permits, and feePercent stops compiling until you handle Voucher. With an open interface and a default branch, the new type would have silently taken the default fee.

Edge cases

  • Anonymous and local classes can never be permitted subtypes.
  • A non-sealed subtype reopens that branch, so switches must cover it with a type pattern for it (or a default).
  • Sealing an interface doesn't stop other code from using it as a type; it only restricts who implements it.
  • In the unnamed module, permitted subclasses in another package are a compile error even if they're public.

Common mistakes

  • Thinking sealed means "can't be subclassed". It means "can only be subclassed by these".
  • Adding a default branch to switches over sealed types, which throws away the exhaustiveness check.
  • Forgetting a permitted subtype needs one of final, sealed or non-sealed (records get final for free).

Likely follow-up

"Sealed interface or enum?" Use an enum when every case is a single fixed instance with the same fields. Use a sealed interface with records when the cases carry different data, like a card number versus an IBAN.

Get every deep dive in the app

Coming soon to the App StoreComing soon to Google Play