Back to Advanced Topics
Q22Question 22 of 41

How does sealing improve `instanceof` checks and exhaustive `switch` pattern matching?

Key Concepts
  • Sealing gives the compiler the complete, finite list of permitted subtypes so it can check exhaustiveness.
  • An exhaustive switch over a sealed type needs no default branch because every possible subtype is covered.
  • If a new subtype is added to the sealed type, every non-exhaustive switch fails to compile, catching gaps at compile time.
  • Adding a default branch to a sealed switch defeats the safety because it silently absorbs new subtypes.
  • Pattern matching with sealed types lets you test and destructure in one step using record patterns.
  • Multi-level sealed hierarchies are expanded transitively for exhaustiveness checking.

Definition

Sealing makes the subtype set finite and known, so the compiler can prove a switch is exhaustive and force you to handle every case without a default.
  • Sealed type – restricts which classes may extend it; the compiler knows the complete, finite set of subtypes.
  • Exhaustiveness checking – the compiler verifies every permitted subtype is covered in a switch; no default needed.
  • Compile-time safety – adding a new subtype causes every incomplete switch to fail compilation, not silently fall through.
  • Pattern matching – switch patterns test type and bind variables in one step; record patterns destructure components.
  • No default rule – deliberately omit default on sealed switches to keep the compile-time safety net.

Imagine National Bank of India has a fixed, closed list of exactly three account products. Because the list is closed, a clerk can write a checklist with one row per product and be certain it is complete. If head office later adds a fourth product, every incomplete checklist is immediately flagged. Sealing is what makes the list closed, so checklists can be provably complete.

Understanding the Concepts

What is the problem before sealed types?

Before sealed types, any type could be extended by anyone, anywhere. The compiler could never know all subtypes. A switch or chain of instanceof checks always needed a default or else for unknown subtypes. If a new subtype appeared, the default silently swallowed it, often causing a runtime bug.

JAVA
// Before sealing: open hierarchy
public interface TransactionResult {}
public class Approved implements TransactionResult { /* ... */ }
public class Declined implements TransactionResult { /* ... */ }

// Anyone can add:
public class Pending implements TransactionResult { /* ... */ }

// Switch must have default because set is unknown
String handle(TransactionResult result) {
    return switch (result) {
        case Approved a -> "OK";
        case Declined d -> "Denied";
        default -> "Unknown"; // Silently swallows Pending!
    };
}

How does sealing fix this?

Sealing gives the compiler the complete list of permitted subtypes. A switch with a case for each subtype is provably complete, so no default is needed.

JAVA
package com.bank.model;

import java.math.BigDecimal;

public sealed interface TransactionResult
    permits Approved, Declined, Pending {}

public record Approved(BigDecimal amount) implements TransactionResult {}
public record Declined(String reason) implements TransactionResult {}
public record Pending(String referenceId) implements TransactionResult {}
JAVA
package com.bank.service;

import com.bank.model.*;

public class ResultHandler {
    public static String handle(TransactionResult result) {
        return switch (result) {
            case Approved a  -> "Approved: " + a.amount();
            case Declined d  -> "Declined: " + d.reason();
            case Pending p   -> "Pending: " + p.referenceId();
            // No default needed: exhaustive!
        };
    }
}

What happens when a new subtype is added?

If a developer adds a new permitted subtype, every exhaustive switch that does not handle it fails to compile. This turns a runtime gap into a compile-time error.

JAVA
// A teammate adds:
public record Reversed(String txnId, String reason)
    implements TransactionResult {}

// Now the permits clause must include Reversed:
public sealed interface TransactionResult
    permits Approved, Declined, Pending, Reversed {}

// COMPILE ERROR in ResultHandler:
// "switch expression does not cover all possible input types"
// Compiler forces you to add: case Reversed r -> ...

Why should you NOT add a default to a sealed switch?

Adding default to a sealed switch defeats the safety. The default absorbs any new subtype silently, so the compiler no longer warns you.

JAVA
// BAD: default hides the safety net
return switch (result) {
    case Approved a -> "Approved";
    case Declined d -> "Declined";
    default -> "Other"; // Pending and Reversed silently go here!
};

// GOOD: no default, compiler enforces completeness
return switch (result) {
    case Approved a  -> "Approved: " + a.amount();
    case Declined d  -> "Declined: " + d.reason();
    case Pending p   -> "Pending: " + p.referenceId();
};

How does pattern matching with records work?

Record patterns destructure the components directly in each case, so you bind the fields without any cast.

JAVA
package com.bank.service;

import com.bank.model.*;
import java.math.BigDecimal;

public class PatternExample {
    public static String describe(TransactionResult result) {
        return switch (result) {
            case Approved(var amount)
                -> "Rajesh's transfer of " + amount + " approved.";
            case Declined(var reason)
                -> "Transfer declined: " + reason;
            case Pending(var refId)
                -> "Transfer pending, ref: " + refId;
        };
    }

    public static void main(String[] args) {
        System.out.println(describe(
            new Approved(new BigDecimal("25000.00"))));
        System.out.println(describe(
            new Declined("Insufficient funds")));
    }
}
TEXT
Rajesh's transfer of 25000.00 approved.
Transfer declined: Insufficient funds

How do guarded patterns work with sealed types?

You can refine within the closed set using when guards, for example routing high-value approvals to a compliance review.

JAVA
package com.bank.service;

import com.bank.model.*;
import java.math.BigDecimal;

public class ComplianceRouter {
    private static final BigDecimal THRESHOLD = new BigDecimal("100000.00");

    public static String route(TransactionResult result) {
        return switch (result) {
            case Approved(var amt) when amt.compareTo(THRESHOLD) > 0
                -> "High-value approval: route to compliance.";
            case Approved(var amt)
                -> "Standard approval: " + amt;
            case Declined d
                -> "Declined: " + d.reason();
            case Pending p
                -> "Pending: " + p.referenceId();
        };
    }

    public static void main(String[] args) {
        System.out.println(route(
            new Approved(new BigDecimal("500000.00"))));
        System.out.println(route(
            new Approved(new BigDecimal("5000.00"))));
    }
}
TEXT
High-value approval: route to compliance.
Standard approval: 5000.00

How do multi-level sealed hierarchies work?

If a permitted subtype is itself sealed, the compiler expands the set transitively. You can switch on the intermediate sealed type or on its leaves.

JAVA
package com.bank.model;

import java.math.BigDecimal;

public sealed interface Account permits SavingsAccount, CurrentAccount {}
public sealed interface SavingsAccount extends Account
    permits RegularSavings, SeniorSavings {}
public record RegularSavings(BigDecimal balance) implements SavingsAccount {}
public record SeniorSavings(BigDecimal balance) implements SavingsAccount {}
public record CurrentAccount(BigDecimal balance) implements Account {}

// Switch on Account covers all leaves:
// RegularSavings, SeniorSavings, CurrentAccount

What about instanceof with sealed types?

For instanceof with pattern matching, sealing bounds the possibilities. While an if-else chain still needs a final else, the closed set documents exactly what types are possible.

JAVA
if (result instanceof Approved a) {
    System.out.println("Amount: " + a.amount());
} else if (result instanceof Declined d) {
    System.out.println("Reason: " + d.reason());
} else if (result instanceof Pending p) {
    System.out.println("Ref: " + p.referenceId());
}
// Modern switch patterns are preferred for exhaustiveness

What should a senior engineer know?

The pairing of sealed types with pattern-matching switches is Java's realization of algebraic-data-type pattern matching from functional languages. In banking, where forgetting to handle a new transaction state could cause financial errors, the compile-time guarantee is enormously valuable. Deliberately omit default to keep the safety net.

Visual Overview

DIAGRAM
  Before Sealing (open hierarchy):

  TransactionResult (interface)
       │
       ├── Approved
       ├── Declined
       ├── Pending
       └── ??? (anyone can add)

  switch needs default → new subtypes silently absorbed

  After Sealing (closed hierarchy):

  sealed TransactionResult permits Approved, Declined, Pending
       │
       ├── Approved  (final record)
       ├── Declined  (final record)
       └── Pending   (final record)

  switch is exhaustive → no default
  Add new subtype → compiler error at every incomplete switch

Key Takeaway

Sealing gives the compiler complete knowledge of the finite set of subtypes. Before sealed types, a switch or instanceof chain always needed a default to handle unknown subtypes, and that default silently swallowed any new subtype, causing runtime bugs. With sealing, the compiler checks exhaustiveness: a switch with a case for each permitted subtype is provably complete and needs no default.

The real power shows when the set changes. Adding a new permitted subtype causes every non-exhaustive switch to fail compilation. This turns a dangerous runtime gap into a compile-time error. In banking, where forgetting to handle a new transaction state could cause financial errors, this guarantee is enormously valuable.

Record patterns make it better still. You bind components directly in each case, extracting the amount from an approval or the reason from a decline without any cast. Guarded patterns with when let you refine within the closed set, like routing high-value approvals to compliance.

At National Bank of India, Pankaj modeled transaction results as a sealed interface with Approved, Declined, and Pending record subtypes. The dispatcher switches over all three with no default. When a teammate later added Reversed, the compiler pointed to every switch that needed updating. That is why you deliberately omit default on sealed switches: adding one would hide the safety net.

Remember one sentence: sealing closes the subtype set so the compiler can prove a switch is exhaustive, and adding a new subtype fails compilation everywhere it is not handled.

---

Share
Use ← → arrow keys to navigate