Syllabus · A3
Sealed hierarchy for a closed set of variants — when to close the set
The idea
Closing a set is a promise, and the compiler holds you to it
A logger writes a formatted line to one destination today and will need a second destination tomorrow, so its interface should not care which. corpus/logger's Appender is written that way: one method, one destination, one finished line, and nothing more specific than that. It works, and it stays open. There is no fixed list of destinations anywhere in it.
Curveball 03 asks for a rolling-file destination that starts a new file every so many lines, and the reference solution absorbs it without opening a single existing file. Its curveballs/03-rolling-appender/budget.json records the cost of that change as reference_diff: 0. Leaving the set open cost nothing when the new destination arrived.
Now look at a type that made the opposite choice on purpose. corpus/snake-and-ladder's SquareEffect is declared sealed permits Plain, Snake, Ladder rather than left open, so the compiler, not a comment and not a convention, enforces that those three are the only kinds of square there are. Curveball 01 adds a power-up square, and the same instrument that read zero for the logger reads six here.
Most of that six is the seal doing exactly what it was declared to do. Two places in the code switch on which kind of SquareEffect a square holds. A sealed type forces each of those switches to add a case for the new kind or fail to compile, and that accounts for four of the six lines. The other two lines are ordinary board data for the new square, the same cost a new square would carry whether or not anything nearby was sealed.
Six lines against zero, so it is fair to ask why anyone would close a set at all. The answer is in when the six lines showed up: javac named those two switches, by file and by line, before a single test ran. Appender needed no such list, because nothing anywhere in the logger ever asks which destination it is holding. It only calls write and trusts the destination to do its job. That is the line to watch for: code that asks which variant it has is code that changes the moment the answer does. Leave the set open, and every one of those asking places gives a wrong answer quietly, at run time, for as long as it takes someone to notice. Seal the set, and every one of those asking places gives a build failure instead, named before anyone runs anything at all.
So the question was never really whether the list happens to be finite today. Every list is finite today. The question that actually decides it is this one:
A
<Thing>is an A, or a B, or a C, and nothing else. The next kind gets written by ______.
If the blank is "me, in this file," seal it. A sealed type is the one construct in Java that nobody outside its own file can extend. That is exactly the guarantee you want when you are the one who will write the next case yourself. If the blank names another team, a plugin author, or a row in a config table, leave the set open even though the list happens to finish today. The next kind is going to come from somewhere sealing cannot reach in the first place.
J11 owns the mechanics of writing a sealed hierarchy. This lesson is the commitment behind that syntax, and worked/ shows what it costs to throw that commitment away by force once you have made it.
Worked walkthrough
NOTES — one word in permits, three compiler errors, and the arm that hides all three
Two trees here, and the difference between them is one word.
src/iscorpus/trip-state-machinecondensed: a sealedTripEventof five records, three dispatch sites over it, and the same three sites written with adefaultarm.src-with-sixth-event/is the same code aftercurveballs/01-driver-cancels, which adds a sixth event.
Run both
From each directory in turn: