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
-
Build the sealed hierarchy.
sealed interface ChargewithTiffins,DeliveryandLateFeeas records. WriteamountPaise(Charge)as an exhaustive switch with record patterns and nodefault. -
Break exhaustiveness. Delete one case and read the error. Then try to implement
Chargefrom a class not in thepermitsclause and read that error too. -
Add a case. Add a
Refundrecord to the hierarchy and let the compiler list every switch that needs updating. Then rewrite one of those switches with adefault, add a sixth type, and note what the compiler now says — nothing. -
Use a guard. Flag subscriptions of more than 25 tiffins as a full month. Then put the unguarded
case Tiffins tfirst and read the compile error. -
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. -
Harder — a tiny expression evaluator. A sealed interface
Exprwith recordsNum(long value),Add(Expr left, Expr right),Multiply(Expr left, Expr right)andNegate(Expr inner). Writelong eval(Expr e)as a single exhaustive switch with record patterns, andString render(Expr e)producing(2 + (3 * 4)). Then addDivide(Expr left, Expr right)and let the compiler point you at both methods. Deciding whatDividedoes 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