A Payment with three booleans — pending, cancelled, refunded — looks harmless until someone instantiates new PaymentFlags(true, true, true). It compiles, it runs, and it hands back a label. The type never said that combination was forbidden; it just had three bits and no rule tying them together.
final class PaymentFlags {
boolean pending, cancelled, refunded;
PaymentFlags(boolean pending, boolean cancelled, boolean refunded) {
this.pending = pending; this.cancelled = cancelled; this.refunded = refunded;
}
String label() {
if (pending) return "pending";
if (cancelled) return "cancelled";
if (refunded) return "refunded";
return "unknown";
}
public static void main(String[] args) {
System.out.println(new PaymentFlags(true, true, true).label());
}
}
javac --release 25 -d out PaymentFlags.java
java -cp out PaymentFlags
Output: pending. The first if resolves the rarest case and hides the other two. It works until it doesn't.
Three independent booleans represent 2^3 = 8 possible combinations. The payment domain has four real states — pending, paid, cancelled, refunded — not eight. The four leftover combinations (pending+cancelled+refunded among them) shouldn't even compile. That's what this article delivers: turn "impossible state" into a javac error, not a bug a customer reports.
Prerequisites
The examples run on Eclipse Temurin 25.0.4+7 LTS, compiled with javac --release 25, no --enable-preview. None of the features used here is new in JDK 25: sealed classes have been final since JDK 17 (JEP 409), and pattern matching for switch alongside record patterns have been final since JDK 21 (JEP 441, JEP 440). JDK 25 shows up here only because it's the host's current LTS, not because the article depends on anything it introduced.
Modeling the valid states
Instead of three loose booleans, a sealed interface that lists exactly who's allowed to implement it, and one record per state:
sealed interface PaymentState permits Pending, Paid, Cancelled, Refunded {}
record Pending() implements PaymentState {}
record Paid(long amountCents) implements PaymentState {
Paid {
if (amountCents < 0) throw new IllegalArgumentException("negative");
}
}
record Cancelled(String reason) implements PaymentState {}
record Refunded(long amountCents) implements PaymentState {}
permits closes the list of implementers; each one has to be final, sealed, or non-sealed, and records are already final by definition. JEP 409 calls this combination an algebraic data type: sealed is the sum (one of four), record is the product (each one's fields). Paid carries amountCents, Cancelled carries reason, and there's no value — because the type doesn't allow it — that is Pending and Cancelled at the same time.
Verified on this host: PaymentState.class.isSealed() returns true, and PaymentState.class.getPermittedSubclasses().length returns 4 (Class API). Running javap -verbose PaymentState shows a PermittedSubclasses attribute listing the four classes — there's no separate ACC_SEALED flag; that attribute is the sealing.
Trying to extend it from outside fails at compile time:
record Extra() implements PaymentState {}
Unauthorized.java:1: error: class is not allowed to extend sealed class: PaymentState (as it is not listed in its 'permits' clause)
Incremental migration without breaking legacy boolean readers
Nobody rewrites every caller of isPending() in one sprint. The path that compiles today is to keep a single PaymentState and derive the old booleans from it — never the two side by side:
final class Payment {
private final PaymentState state;
Payment(PaymentState state) { this.state = state; }
PaymentState state() { return state; }
boolean isPending() { return state instanceof Pending; }
boolean isCancelled() { return state instanceof Cancelled; }
boolean isRefunded() { return state instanceof Refunded; }
boolean isPaid() { return state instanceof Paid; }
String legacyLabel() {
if (isPending()) return "pending";
if (isCancelled()) return "cancelled";
if (isRefunded()) return "refunded";
if (isPaid()) return "paid";
return "unknown";
}
}
isPending() and isCancelled() never return true at the same time, because both query the same state field. There's no way to desync what doesn't exist in duplicate. Keeping the three booleans and the PaymentState field reintroduces the original problem — the two can disagree. New code switches on state(); legacy code that still calls isPending() keeps compiling without knowing anything changed underneath.
When a plain boolean is still the right choice
Sealed models a closed set of types; enum models a closed set of instances; boolean models a single yes/no fact. A flag like enabled, verbose, or a lone soft-delete bit has no mutual exclusivity with anything else — every combination is legitimate. Sealing a type to represent one isolated bit is ceremony with no exhaustiveness gain over a plain if (enabled).
The signal that migration is worth it is the opposite: two or more booleans describing stages of the same lifecycle, where at least one combination is forbidden by the domain, not by the type. If every 2^n combination is valid, stay with booleans (or a record of booleans, if you want to group them). And don't declare an implementer non-sealed just to "leave it open for later" — that reopens the branch, and the switch goes back to treating that arm as a catch-all, hiding unknown subclasses exactly where you wanted exhaustiveness.
Verification: the exhaustive switch and the error you want to see
With PaymentState sealed, a switch that covers all four permits doesn't need default:
final class Labels {
static String label(PaymentState s) {
return switch (s) {
case Pending() -> "pending";
case Paid(long amount) -> "paid:" + amount;
case Cancelled(String reason) -> "cancelled:" + reason;
case Refunded(long amount) -> "refunded:" + amount;
};
}
}
Removing case Refunded on purpose:
SealedMissing.java:3: error: the switch expression does not cover all possible input values
return switch (s) {
^
That's the payoff: forgetting a state becomes a build error, not silent behavior in production. And here's the trap that's easy to fall into without noticing — adding default -> "other" alongside the missing case Refunded makes the same code compile again. default looks like a kindness; in practice it's the compiler promising to cover a hole you never patched. If the goal is exhaustiveness, don't write default over a sealed selector.
Two more checks, already covered by the same model:
label(null)throwsNullPointerException— a pattern-matchingswitchwithoutcase nulldoesn't acceptnullimplicitly.new Paid(-1)throwsIllegalArgumentException, coming from the record's compact constructor — that validates the value of a specific state, not exhaustiveness across states; they're different concerns, and each lives in the right place.
Running the full harness:
javac --release 25 -d out PaymentState.java Payment.java Labels.java
java -cp out Labels
true
4
paid:100
1
NEG:negative
legacy:refunded
both:false
NPE
Each line confirms one fact: isSealed() is true, four permitted subtypes, the switch decomposes Paid(100) into paid:100, the amountCents() accessor returns 1, the compact constructor rejects -1, the legacy adapter (Payment) returns legacy:refunded, isPending() && isCancelled() is false, and label(null) throws the exception instead of returning some arbitrary label.
Production observability
The runnable fix is already above: the PaymentState + Payment pair as the single source of truth, with the exhaustive switch blocking unhandled new states. For observability, swap logs and metrics that today record three separate booleans for a single field pulled from the type:
log.info("payment_state={}", payment.state().getClass().getSimpleName());
That guarantees the dashboard always gets a value from a closed, known set (Pending, Paid, Cancelled, or Refunded), never a combination of bits nobody anticipated when the alert was set up.
Recommendation
DevDojo adopts sealed interface + records as soon as a domain type joins two or more booleans describing the same lifecycle — payment, order, session, whatever it is — and at least one combination is forbidden by the business. The incremental migration (keep PaymentState, derive the old booleans) avoids rewriting callers all at once.
Pull back when the flags are genuinely independent — every 2^n combination makes sense — or when the set of states changes often and every new permits forces you to touch every exhaustive switch in the codebase; in that case the rigidity that helps today turns into friction tomorrow, and an enum or a record of flags is cheaper to maintain.
Next step: take the type in your project with more than two related booleans, write down on paper the states the business actually allows, and start with the sealed interface — the read adapter for legacy code can wait until the first switch breaks at build time.