RizTech Academy logo
RizTech Academy
Object-Oriented JavaLesson 8 of 830 min

Sealed types and pattern matching

The enums lesson ended on a promise: the compiler can tell you every place you forgot to handle a new case, and that is worth more than any amount of careful reviewing.

Enums give you that for a fixed set of constants. Java 21 gives you the same guarantee for a fixed set of types — each carrying its own data. This is the most significant thing to arrive in the language since Java 8, and almost no college syllabus mentions it.

The problem: a family of things that are not all alike

A bill has several kinds of line. Tiffins have a count and a unit price. A delivery charge is a flat amount. An adjustment has an amount and a reason.

Inheritance would give them a common parent, and then every operation becomes a method on the parent — which is fine until the operations belong somewhere else, like a report formatter or a tax calculator that has no business living inside Charge.

The old alternative was instanceof and casts, and it looked like this:

if (c instanceof Tiffins) {
    Tiffins t = (Tiffins) c;
    return t.count() * t.unitPaise();
} else if (c instanceof Delivery) {
    Delivery d = (Delivery) c;
    return d.paise();
} else {
    throw new IllegalStateException("unknown charge");
}

Three problems. The cast repeats what the test just proved. Nothing checks you covered everything. And that final throw is a runtime failure standing in for a compile-time check.

All three are now fixed.

Step one: instanceof with a pattern

if (o instanceof String s && s.length() > 3) {
    System.out.println(s.toUpperCase());
}
PRIYA

o instanceof String s tests and declares s, already cast. It is in scope wherever the test must have been true — including the right-hand side of the && on the same line, which is why the length() call compiles.

This alone removes most casts from ordinary code. It has been available since Java 16.

Step two: sealed

sealed interface Charge permits Tiffins, Delivery, LateFee, Adjustment { }

record Tiffins(int count, int unitPaise) implements Charge { }
record Delivery(long paise)             implements Charge { }
record LateFee(long paise)              implements Charge { }
record Adjustment(long paise, String reason) implements Charge { }

sealed says: these four types, and no others, ever. Try to add a fifth from elsewhere:

error: class is not allowed to extend sealed class: Charge (as it is not listed in its 'permits' clause)
class Rogue implements Charge {
^

Every permitted subtype must itself be final, sealed, or explicitly non-sealed. Records are final automatically, which is why records and sealed interfaces are so often used together.

If the permitted types are in the same file, you may omit permits entirely and the compiler infers it.

Step three: switch over the sealed type

static long amountPaise(Charge c) {
    return switch (c) {
        case Tiffins(int count, int unitPaise) -> (long) count * unitPaise;
        case Delivery(long paise) -> paise;
        case LateFee(long paise) -> paise;
        case Adjustment(long paise, String reason) -> paise;
    };
}
26 tiffins at Rs 82.35             Rs 2,141.10
Delivery                             Rs 35.00
Late payment fee                     Rs 50.00
Adjustment: missed delivery         -Rs 12.00
Total                              Rs 2,214.10

Two things are happening in each case.

A type pattern selects the branch. A record pattern — Tiffins(int count, int unitPaise) — destructures it, binding each component to a name in one go. No cast, no accessor calls, no temporary variable.

And no default. Delete one case:

error: the switch expression does not cover all possible input values
        return switch (c) {
               ^

That is the whole point. Add a fifth charge type to the sealed interface and every switch that has not decided what to do about it fails to compile, in every file, by line number. Compare that with the instanceof chain, where the new type falls into an else and throws in production at month end.

Guards: when

A pattern can carry a condition:

static String flag(Charge c) {
    return switch (c) {
        case Tiffins(int count, int unit) when count > 25 -> "full month";
        case Tiffins t -> "part month";
        case Adjustment(long paise, String reason) when paise < 0 -> "credit: " + reason;
        case Adjustment a -> "charge";
        default -> "ordinary";
    };
}
full month
ordinary
ordinary
credit: missed delivery

Cases are tried in order, so the guarded case must come before the unguarded one. Reverse them and the compiler tells you the second is unreachable — a better outcome than the silent wrong answer an if/else chain in the wrong order would give.

Note this example does have a default, because the guards mean the compiler cannot prove the Tiffins and Adjustment cases cover those types entirely. That is correct and worth understanding: exhaustiveness is about types, and a guard is not a type.

null

Historically switch threw NullPointerException on a null selector — the control-flow lesson warned about it. A pattern switch lets you handle it:

return switch (c) {
    case null -> "nothing to bill";
    case Tiffins t -> "tiffins";
    default -> "some charge";
};
nothing to bill
some charge

Without a case null, the old behaviour is kept for compatibility: it throws. Adding case null is explicit, visible, and better than a null check three lines above that somebody will delete.

Nested patterns

Patterns compose, which is where this starts to feel like a different language:

record Customer(String name, String pincode) { }
record Invoice(Customer customer, Charge charge) { }

if (invoice instanceof Invoice(Customer(String name, String pincode),
                               Tiffins(int count, int unit))) {
    return "%s (%s): %d tiffins at %s".formatted(name, pincode, count, rupees(unit));
}
Priya (411207): 26 tiffins at Rs 82.35

One test reaches two levels down, checks the charge is a Tiffins, and binds four values. The equivalent with getters and casts is eight lines and a null check.

Keep it to two levels. Three is clever and unreadable.

Sealed hierarchy against the alternatives

Sealed interface + records Enum Ordinary inheritance
Fixed set of cases Yes Yes No — anyone can extend
Each case carries different data Yes Only what every constant has Yes
Exhaustiveness checked Yes Yes No
Add operations without editing the types Yes — a new switch elsewhere Awkward No — a new method on the parent
Add a case without editing operations No, deliberately No, deliberately Yes, silently

That last row is the real trade and it cuts both ways. Sealed types make adding a new case expensive and adding a new operation cheap. Inheritance is the reverse: a new subclass costs nothing, a new operation means touching every class.

Choose by which one you expect to do more often. A set of charge types that changes twice a year and a set of reports that grows monthly is exactly the sealed case. An open-ended plugin system that third parties extend is exactly not.

A practical note on money

The formatter used above handles negative amounts explicitly:

static String rupees(long paise) {
    String sign = paise < 0 ? "-" : "";
    long abs = Math.abs(paise);
    return "%sRs %,d.%02d".formatted(sign, abs / 100, abs % 100);
}
-1250 -> -Rs 12.50
 1250 ->  Rs 12.50
   -5 -> -Rs 0.05

The naive version from module 2 — paise / 100 and paise % 100 without the Math.abs — prints -1250 as Rs -12.-50, because both halves come out negative. Credits and refunds are exactly where that shows up, so fix it once and keep the method.

Check your work

What does o instanceof String s do that o instanceof String does not? Declares s, already cast, in scope wherever the test must have been true.

What does sealed guarantee? That the listed types are the only implementers that will ever exist. Anything else gets not allowed to extend sealed class ... as it is not listed in its 'permits' clause.

What must each permitted subtype be? final, sealed, or explicitly non-sealed. Records are final automatically.

What is a record pattern? A pattern that destructures a record into its components — case Tiffins(int count, int unitPaise) binds both in one step, with no cast and no accessor calls.

Why omit default when switching over a sealed type? So that adding a new permitted type breaks every switch that has not handled it, at compile time, by line number.

Why did the guarded example still need a default? Because a guard is a condition, not a type. The compiler cannot prove when count > 25 plus the unguarded case covers everything, so exhaustiveness is not established.

In what order must guarded and unguarded cases of the same type appear? Guarded first. The other way round makes the guarded case unreachable, and the compiler says so.

When is a sealed hierarchy the wrong choice? When third parties need to extend the type, or when new cases arrive far more often than new operations.

Practice 3, adding a case. Adding record Refund(long paise, String reason) to the permits clause makes every exhaustive switch fail to compile, each error naming a file and line. Fixing them is a checklist the compiler wrote. With the instanceof/else version, the code builds, the refund falls into the else, and either throws at month end or is billed as zero.

Practice 5, the nested pattern. The getter version needs a cast, a null check on the customer, and four accessor calls:

if (invoice.charge() instanceof Tiffins) {
    Tiffins t = (Tiffins) invoice.charge();
    Customer cust = invoice.customer();
    return "%s (%s): %d tiffins at %s".formatted(
            cust.name(), cust.pincode(), t.count(), rupees(t.unitPaise()));
}

The pattern version is one if and no casts. The readability gain is real; the correctness gain is that there is no way to cast to the wrong type.

Practice

  1. Build the sealed hierarchy. sealed interface Charge with Tiffins, Delivery and LateFee as records. Write amountPaise(Charge) as an exhaustive switch with record patterns and no default.

  2. Break exhaustiveness. Delete one case and read the error. Then try to implement Charge from a class not in the permits clause and read that error too.

  3. Add a case. Add a Refund record to the hierarchy and let the compiler list every switch that needs updating. Then rewrite one of those switches with a default, add a sixth type, and note what the compiler now says — nothing.

  4. Use a guard. Flag subscriptions of more than 25 tiffins as a full month. Then put the unguarded case Tiffins t first and read the compile error.

  5. Destructure two levels. record Invoice(Customer customer, Charge charge). Write a summary line using a nested record pattern, then write the same thing with getters and casts. Count the lines and the casts.

  6. Harder — a tiny expression evaluator. A sealed interface Expr with records Num(long value), Add(Expr left, Expr right), Multiply(Expr left, Expr right) and Negate(Expr inner). Write long eval(Expr e) as a single exhaustive switch with record patterns, and String render(Expr e) producing (2 + (3 * 4)). Then add Divide(Expr left, Expr right) and let the compiler point you at both methods. Deciding what Divide does with a zero denominator is module 7's business, and it is worth leaving a comment there now so that lesson has something to come back to.


That is module three. You can write classes that cannot hold invalid data, choose between inheritance, interfaces and composition with reasons, replace forty-line data classes with a record, and use sealed types so the compiler finds the case you forgot.

Next module: generics — the angle brackets in every signature you have been reading and not quite parsing.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship