LLD Dojo

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

corpus/logger has an Appender: one destination for one finished line. Curveball 03 adds a rolling-file destination, and curveballs/03-rolling-appender/budget.json reads reference_diff: 0. A new variant, and no existing file opened.

Now corpus/snake-and-ladder, where SquareEffect is sealed permits Plain, Snake, Ladder. Curveball 01 adds a power-up square and the same instrument reads 6. Four of the six are the seal: one forced case in each of the two switches over the type, and two rows of board data.

Six against zero, so why close a set at all? Because javac named those two switches, by line, before a test ran. Appender gets no such list and needs none, since nothing in the logger asks which destination it holds. Code that asks which variant it has changes the answer. An open set then gives a wrong answer per dispatch site, silently. A closed set gives a build failure per site.

So the question is not whether the list is finite. Finish this instead:

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. If it names another team, a plugin, or a config row, leave the set open even though the list finishes today. A sealed type is the one thing in Java nobody outside its own file can extend.

J11 owns the syntax. This is the commitment, and worked/ shows the arm that throws it away.


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.

Run both

From each directory in turn:

INLINECODE0

-Xlint:all prints nothing for either tree. No warnings, no serial complaints, because there is no exception class in here.

src/Main, verbatim:

INLINECODE1

Block 2 is why this lesson is hard to teach. Ten trues. Today the careful design and the sloppy one behave identically, so no test you can write now tells them apart.

src-with-sixth-event/Main, verbatim:

INLINECODE2

your driver is outside, sent to a rider whose driver has driven away, is the sentence to remember. Nothing threw. Nothing warned. The push went out.


The default arm, demonstrated twice with the same widening

One requirement, one word added to permits, two compilations. Both outputs are real, from the files in this lesson.

Without default. The six-variant tree with src/Rides.java in place of the updated one, so the strict dispatch has not yet been touched:

INLINECODE3

Exit 1. Three sites, three line numbers, before a test ran. That list is the product being bought.

With default. The same tree with Rides.java removed, so only LenientRides dispatches:

INLINECODE4

Exit 0, and two wrong answers shipped. Neither method was edited and neither was asked.

The rule that follows has no exception worth the words. A switch over a sealed type never carries a default. When an arm has no answer you like, throw in it. corpus/file-system's Nodes.kindOf does exactly that in one arm, for a case its own resolver guarantees cannot happen.

One default was right, and that is worth reporting

endsTheTrip lenient answers false for the sixth event, which is correct. A driver cancellation returns the trip to REQUESTED and it keeps accepting events.

So a default is not always wrong. It is always unchecked. Two of the three sites here were wrong and one was right, and nobody made a decision about any of them. A lesson claiming three out of three would be easier to write and would not survive the reader running it.

How javac hands you the worklist, and where it stops

The three errors above are all in one file, so one compilation reported all three. Errors in different files behave differently, and the corpus records this. Compiling corpus/trip-state-machine/reference/src with the sixth event added and nothing else changed:

INLINECODE5

One error, then the compiler stops. Hold Demo.java back and the next appears:

INLINECODE6

Hold that back too:

INLINECODE7

Three sites, reported one at a time. That is a property of the compiler, not of the design. It is why "fix it until it builds" is the right loop. Reading the first error and assuming it is the only one would have left two sites wrong.


src/TripEvent.java — the line that makes the commitment

INLINECODE8

What this guarantees: every switch over TripEvent, anywhere in the program, is checked against this list. What it costs: no class outside this file can ever implement TripEvent. Both halves come from the same declaration and you cannot take one.

J11's notes cover how sealed and permits work, including why every permitted type must be final. What belongs here is the decision, and the sentence that makes it:

A trip event is matched, arrived, started, completed, or rider-cancelled, and nothing else. The next kind gets written by ______.

The blank is "the trip team, in this file". Dispatch, the rider app and a partner fleet all send events. None of them invents an event type, because an event type carries a transition rule and a rider message that only this codebase knows. So the set closes.

Run the same sentence on corpus/logger/contract/Appender.java and the blank reads "whoever has somewhere to put a line". That is why it is an ordinary interface, and curveballs/03-rolling-appender/budget.json records what the openness was worth: reference_diff: 0, with reference/src byte-identical before and after.

Two checks, pointing opposite ways. The first pushes you to close: you can finish the list, and you own the next entry, so close it and take the compile errors. The second pulls you back: you can finish the list today, and somebody outside your file owns the next entry, so leave it open. A threshold with only the first half is what produces sealed on every interface in a submission.

How rare the answer is, measured. contract/ and reference/src/ across the twenty corpus problems declare 87 interfaces. Four are sealed: Node in file-system, TripEvent here, SquareEffect in snake-and-ladder, and Segment in middleware-router. Reproduce it:

INLINECODE9

Read those four and the pattern is the same every time. Each names a set the problem statement fixes. The kinds of thing at a path, the alphabet of a state machine, what a square does, the kinds of segment in a route pattern. None of them is a behaviour someone might want to plug into.


src/Rides.java — three sites, and the arms that look like noise

INLINECODE10

Four of those five arms return the parameter unchanged, and deleting them is the mistake. They are not repetition. They are five recorded answers to "does this event change who the driver is?" The count of arms is what makes the sixth event a build failure rather than a silent inherit.

The real file this comes from says so in its own javadoc. TripProjection.driverOf in corpus/trip-state-machine/reference/src carries the same four pass-through arms and the same absent default, and curveballs/01-driver-cancels/reference-patch/PATCH.md calls the added arm one of "the three lines that are the actual point of this curveball".

INLINECODE11

The third site exists because a wrong answer here is worse than a wrong message. A trip wrongly reported as over refuses every later event. The rider is not misinformed, the rider is stuck. Worth knowing which of your dispatch sites is the expensive one before an interviewer asks.

Three sites over one type is also the honest count for a design this small. corpus/trip-state-machine has three too, in three separate files. That number is the size of the worklist you are buying, and it is small in every corpus problem that seals anything.


src/LenientRides.java — the version a reviewer will ask you for

INLINECODE12

Six lines became two, and every deleted line was one a reader would call noise. This is not a straw man. It is what a careful engineer writes when the four pass-through arms look like an accident, and today it behaves identically, which block 2 of src/Main prints as ten trues.

INLINECODE13

The default sits where DriverArrived used to, and that placement is the whole bug. DriverArrived was the last variant with nothing to bind, so folding it into the fallback is exactly the simplification that looks safe. Then a sixth variant arrives and inherits a sentence about a driver being outside. The fallback answer is not neutral. It is whichever arm you happened to fold into it.

That is the reason the rule is absolute rather than a preference. You cannot pick a default answer that is right for a variant nobody has described yet.


src/LooseEvent.java — sealed versus enum, and the usual claim corrected

The choice you will actually face at a whiteboard is not sealed against instanceof. It is sealed against this:

INLINECODE14

It does not lose exhaustiveness, and saying that it does will get you corrected. An enum switch with every constant and no default is checked the same way. Add a sixth constant and leave driverIdOf alone:

INLINECODE15

Same message, same exit 1. So exhaustiveness cannot be the reason to prefer one form.

What it loses is that the combinations mean anything. Two components apply to one Kind each, so the header describes five tags times two optional fields, and five of those shapes are trip events. Block 3 constructs one of the rest:

INLINECODE16

A trip that has not been matched, carrying a driver and a cancellation reason. Nothing refused it. Refusing it means a rule per component in the compact constructor, and that rule is a thing somebody must remember to extend on every new tag.

Against record TripStarted() implements TripEvent, there is no argument to pass. The bad instance is unconstructable, so there is no rule to write and nothing to forget. The corpus makes the same argument in contract/TripEvent.java's own javadoc, which is worth reading once for the phrasing.

The rule, in the form to say out loud:

Both forms give a checked switch, so exhaustiveness does not decide it. Use an enum while every variant carries the same fields, which is usually none. Move to a sealed interface of records the moment two variants need different fields. The enum form then needs one nullable component per variant, plus a validation rule to keep the impossible combinations out.

Apply it to the two types sitting next to each other in this directory. TripState is REQUESTED through CANCELLED with no per-occurrence data, so it stays an enum and the corpus keeps it as one. TripEvent has Matched naming a driver and RiderCancelled naming a reason, so it is sealed records. One problem, both answers, and the rule picks each correctly.

A1 owns the enum side in full, including what belongs on a constant and what belongs behind a seam. This lesson only owns the boundary between the two.


What closing the set actually costs, measured on four problems

Every number below is from a budget.json with "measured": true. The two marked verified were re-measured here with measureChange from server/lib/diff.mjs, the same function that scores D4.

Problem, curveballNew variantreference_diffForced switch armspermits line
logger 03-rolling-appendera rolling-file destination0none, Appender is opennone
snake-and-ladder 01-power-up-squaresa power-up square62free, in contract/
trip-state-machine 01-driver-cancelsDriverCancelled8 verified4free, in contract/
file-system 01-shortcutsSymlinkNode28 verified22

Why the permits line is free in two rows and charged in one. measureChange is run over reference/src, and budget.json's measured_with for trip-state-machine says contract/ and contract-delta/ are excluded because they are given. TripEvent and SquareEffect live in contract/, so widening them costs the candidate nothing. Node lives in file-system's own reference/src, so its 2 lines are charged. In a real round the sealed type is always yours, so charge yourself the 2.

The two verified rows, per file, straight from the measurement:

INLINECODE17

Read the 28 carefully, because the headline number is not about sealing at all. Only 4 of the 28 belong to the closed set. Node.java is 2, and that is one word added to a permits list, charged as a removal plus a re-addition because a line diff sees the line rewritten. Nodes.java is 2, the one forced case. The other 24 are the requirement: 16 in TreeResolver for threading a hop count through nested resolution, and 8 in InMemoryFileSystem for a new symlink() method and a fourth arm in write.

J11 states the conclusion, and it is the honest one, so it is repeated rather than improved on. Sealing did not make the change cheap. It made the change complete. The compiler produced the list of decision points and the list had one entry.

The 8 splits differently, and it is the more useful row. Four of its lines are forced switch arms: TripProjection 1, RiderNotification 1, Demo 2. The other 4 are TripRules, and the next section is about why nothing forced those.

The right way to quote these in a round is the ratio, not the total. Across the three sealed problems the seal's own cost is 2 to 4 lines every time: one word in permits, plus one arm per switch. What varies by a factor of five is the requirement sitting underneath it.

One row is not independently verified and should be read as the corpus's own number. snake-and-ladder's 6 comes from its budget.json note, which attributes 4 to the sealed hierarchy: two rows of board data and one case in each of BoardGeometry and Demo. It cannot be re-measured by overlay, because its reference-patch/ ships only the new file and gives the two edits as diff hunks in PATCH.md.


The site the compiler does not name

Look again at the trip-state-machine row. Four files changed, and only three of them were reported by javac. The fourth is TripRules, at 4 lines, the largest single entry.

TripRules builds the transition table, and its rows are keyed by Class<? extends TripEvent>. A Class object is not exhaustiveness-checked, so a sixth event with no row compiles fine and is refused at run time. PATCH.md names this and does not soften it:

Note what did not break: TripRules. Edge keys on Class<? extends TripEvent>, which is not exhaustiveness-checked

It goes on to say that a sixth event with no row compiles fine and is refused at run time.

Reproduce it by compiling corpus/trip-state-machine/reference/src against the delta and holding back each failing file in turn. Three errors appear, at Demo.java:106, RiderNotification.java:30, and TripProjection.java:52. TripRules.java never appears. The missing rows are caught by the curveball suite instead.

So the guarantee has a precise shape, and it is smaller than the slogan. Sealing checks switch sites. It does not check a map keyed by the type, a registry, a Class lookup, or a configuration file listing variant names. Those are found by tests or by production.

A1's notes record the same boundary from the enum side: an exhaustive switch over VehicleType is checked and the Map<VehicleType, Long> behind PricingPolicy is not, which is why FlatHourlyPricing carries a runtime guard.

This is also the thing to volunteer rather than be caught on. A candidate who says "the compiler catches every site" gets pushed on exactly this point. The good answer is to have counted. Three of my four dispatch sites are switches and the fourth is a table, so the table has a test.


What an interviewer is measuring

Not whether you can spell sealed. Three things, in the order they come up.

Whether you said the set was closed, and what you get for it. One sentence:

A trip event is one of five things and there is no sixth today, so the interface is sealed. Every switch over it is checked, and a new kind of event is a compile error at each site rather than a trip that quietly does the wrong thing.

Whether you named the price without being asked. The price is that nobody outside the file can add a variant. Say who that suits. It suits an event alphabet, a path node, a square effect. It does not suit a destination, a pricing policy, or anything a second team is meant to plug into.

Whether the answer to the follow-up is a number. The follow-up is always "so what happens when a sixth event arrives?" The answer is 8 lines across four files, four of them forced switch arms, and three of the four files named by the compiler before any test ran.


When not to

When not — the interface somebody else has to implement

The habit that costs marks is reaching for sealed because the list looks finite. Four of the 87 interfaces in the corpus's contracts and reference solutions are sealed. The other 83 are the normal case, and one of them makes the cost concrete.

The concrete bad example

corpus/logger/contract/Appender.java is one destination for one finished line:

public interface Appender {
    void write(String formattedLine);
}

Console, file, a list in a test. Three today, and you can name them, so the finite-list test says close it. Seal it and pick the one named implementation:

public sealed interface Appender permits ConsoleAppender {

Compile the untouched reference against that. Real output:

Demo.java:13: error: incompatible types: Appender is not a functional interface
        logger.addAppender(line -> System.out.println(line), Level.INFO,
              ^
Demo.java:19: error: incompatible types: Appender is not a functional interface
        logger.addAppender(auditTrail::add, Level.ERROR,
              ^
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
2 errors

Exit 1. A sealed interface is not a functional interface, so line -> System.out.println(line) and auditTrail::add both stop being destinations. That is a cost nothing in the sealed-hierarchy literature warns about, and it lands on the two shortest destinations in the codebase.

What it costs on the graded instrument

curveballs/03-rolling-appender asks for a destination that starts a new file every N lines. Its budget.json reads reference_diff: 0, and its PATCH.md step 1 is "Change nothing."

The rolling destination in that curveball lives inside its own test file, as private static final class RollingAppender implements Appender. Against a sealed Appender:

Rolling.java:4: error: class is not allowed to extend sealed class: Appender (as it is not listed in its 'permits' clause)
final class Rolling implements Appender {
      ^
1 error

So the seal turns a change measured at 0 lines into a change that cannot be expressed at all without editing a given contract file. On D4 that is the difference between level 3, absorbed by adding files only, and a curveball that does not compile.

The check that would have stopped it

The finite-list question is the wrong half of the test. The second half is the one that decides:

The next kind gets written by ______.

For an Appender the blank is "whoever has somewhere to put a line", and that answer was already on disk. The contract's own javadoc ends by saying a destination is "whatever lambda you like". Read the sentence a contract writes about its own interface before you close it.

Two more places the same mistake is available

A PricingPolicy, a RateLimitAlgorithm, a SpotAllocator. Anything whose variants are behaviours rather than kinds of thing. corpus/rate-limiter/reference/src/RateLimitAlgorithm.java is an ordinary interface with two implementations, and its DECISION_LOG.md records what a third cost: reference_diff = 1, "the single implementations.put(...) line whose whole job is to be the only line." Sealing that set would buy a compile error nobody wanted, since no code switches on which algorithm it holds.

A set you seal to get exhaustiveness you already had. If every variant carries the same fields, an enum gives you the same checked switch with no seal, no permits clause to maintain, and values() for free. worked/NOTES.md quotes both compiler errors side by side to show they are the same message. Sealing there adds files and buys nothing.

The one-line version

Seal a set when code asks which kind is this and you own the answer. Leave it open when the variants are behaviours, when a lambda is a legitimate implementation, or when the next one arrives from outside your file.


Worked source

The 25 files of the worked design

Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.

worked/src-with-sixth-event/DriverArrived.java9 lines

/**
 * The driver reached the pickup point.
 *
 * <p>Carries nothing. Which driver arrived is already known from the {@link Matched} before it, and
 * repeating it here would be a second place for one fact to be wrong. A variant carrying no data is
 * not an argument for an enum on its own — the sibling that carries data is what decides.
 */
record DriverArrived() implements TripEvent {
}

worked/src-with-sixth-event/DriverCancelled.java14 lines

/**
 * The driver backed out. From
 * {@code corpus/trip-state-machine/curveballs/01-driver-cancels/contract-delta/DriverCancelled.java}.
 *
 * <p>The trip is not cancelled. Dispatch goes looking for another car, so the trip returns to
 * {@code REQUESTED} and carries on. That is what makes this variant a good test of a
 * {@code default} arm: the fallback answer for "who is the driver" and "what do we tell the rider"
 * is wrong here, and wrong in a way a reader of the fallback would not predict.
 *
 * @param driverId the driver who backed out; never null
 * @param reason   free text from the driver's app, e.g. "too far"
 */
record DriverCancelled(String driverId, String reason) implements TripEvent {
}

worked/src-with-sixth-event/LenientRides.java51 lines

import java.util.List;
import java.util.Optional;

/**
 * Byte-for-byte the three method bodies from {@code ../src/LenientRides.java}. Not one line changed,
 * and it compiled the moment {@code DriverCancelled} appeared.
 *
 * <p>Which is the point. {@link Rides} needed three edits and could not be built until it got them.
 * This file needed none and was never asked. {@code Main} prints what each of the three answers now.
 */
final class LenientRides {

    /** The sixth event falls into {@code default}, so the fold keeps a driver who has gone home. */
    static Optional<String> driverOf(List<Transition> log) {
        Optional<String> driver = Optional.empty();
        for (Transition entry : log) {
            driver = switch (entry.event()) {
                case Matched matched -> Optional.of(matched.driverId());
                default -> driver;
            };
        }
        return driver;
    }

    /** The sixth event falls into {@code default}, so the rider is told the driver is outside. */
    static String message(TripEvent event) {
        return switch (event) {
            case Matched matched -> "your driver " + matched.driverId() + " is on the way";
            case TripStarted started -> "you are on your way";
            case TripCompleted completed -> "you have arrived after "
                    + completed.distanceMetres() + "m";
            case RiderCancelled cancelled -> "your trip was cancelled: " + cancelled.reason();
            default -> "your driver is outside";
        };
    }

    /**
     * The sixth event falls into {@code default} here too, and {@code false} happens to be right.
     * Report it that way rather than pretending a {@code default} always breaks. It is always
     * unchecked, and this answer was not chosen by anyone.
     */
    static boolean endsTheTrip(TripEvent event) {
        return switch (event) {
            case TripCompleted completed -> true;
            case RiderCancelled cancelled -> true;
            default -> false;
        };
    }

    private LenientRides() {}
}

worked/src-with-sixth-event/LooseEvent.java33 lines

/**
 * The enum form after the same requirement, so the comparison stays fair.
 *
 * <p>{@code DRIVER_CANCELLED} was added to {@code Kind}, and {@code driverIdOf} stopped compiling
 * with the same message the sealed switch produced. {@code NOTES.md} quotes it. So the enum keeps
 * the worklist, and choosing between the two forms is not about exhaustiveness.
 *
 * <p>What it did not keep: a {@code DRIVER_CANCELLED} carries both a driver and a reason, and every
 * other variant with a reason carries no driver. The header now describes 6 tags times 3 optional
 * components, of which six shapes are events. Refusing the rest is a rule somebody maintains.
 */
record LooseEvent(LooseEvent.Kind kind, String driverId, String reason, long distanceMetres) {

    enum Kind {
        MATCHED, DRIVER_ARRIVED, TRIP_STARTED, TRIP_COMPLETED, RIDER_CANCELLED,
        /** ADDED. One constant, and it broke the switch below exactly as the seal did. */
        DRIVER_CANCELLED
    }

    static LooseEvent of(Kind kind) {
        return new LooseEvent(kind, null, null, 0L);
    }

    static String driverIdOf(LooseEvent event) {
        return switch (event.kind()) {
            case MATCHED -> event.driverId();
            // ADDED. And note the answer differs from MATCHED's for the same component being set:
            // the driver is named and is no longer the driver.
            case DRIVER_CANCELLED -> null;
            case DRIVER_ARRIVED, TRIP_STARTED, TRIP_COMPLETED, RIDER_CANCELLED -> null;
        };
    }
}

worked/src-with-sixth-event/Matched.java11 lines

/**
 * Dispatch assigned a driver.
 *
 * <p>The component is why this hierarchy is sealed records and not an enum: {@code Matched} has to
 * say <i>which</i> driver, and an enum constant is a singleton with no room for a per-occurrence
 * value.
 *
 * @param driverId which driver was assigned; never null
 */
record Matched(String driverId) implements TripEvent {
}

worked/src-with-sixth-event/RiderCancelled.java7 lines

/**
 * The rider called the trip off.
 *
 * @param reason free text from the app, e.g. "driver too far". Never null
 */
record RiderCancelled(String reason) implements TripEvent {
}

worked/src-with-sixth-event/Rides.java59 lines

import java.util.List;
import java.util.Optional;

/**
 * {@code ../src/Rides.java} after the three compile errors were answered. The three added arms are
 * marked, and they are the entire edit.
 *
 * <p>Order of events: one word went into {@code TripEvent}'s {@code permits} clause, then
 * {@code javac} reported one error, then the next, then the next. Nobody read this file looking for
 * dispatch sites. {@code NOTES.md} quotes all three messages.
 */
final class Rides {

    static Optional<String> driverOf(List<Transition> log) {
        Optional<String> driver = Optional.empty();
        for (Transition entry : log) {
            driver = switch (entry.event()) {
                case Matched matched -> Optional.of(matched.driverId());
                case DriverArrived arrived -> driver;
                case TripStarted started -> driver;
                case TripCompleted completed -> driver;
                case RiderCancelled cancelled -> driver;
                // ADDED. The trip has been matched before and has nobody now, so the fold has to
                // forget. corpus/.../01-driver-cancels/PATCH.md charges this same arm at 1 line and
                // calls it "the three lines that are the actual point of this curveball".
                case DriverCancelled cancelled -> Optional.empty();
            };
        }
        return driver;
    }

    static String message(TripEvent event) {
        return switch (event) {
            case Matched matched -> "your driver " + matched.driverId() + " is on the way";
            case DriverArrived arrived -> "your driver is outside";
            case TripStarted started -> "you are on your way";
            case TripCompleted completed -> "you have arrived after "
                    + completed.distanceMetres() + "m";
            case RiderCancelled cancelled -> "your trip was cancelled: " + cancelled.reason();
            // ADDED. Not a cancellation from the rider's point of view, so it must not reuse the
            // sentence above it.
            case DriverCancelled cancelled -> "we are finding you another driver";
        };
    }

    static boolean endsTheTrip(TripEvent event) {
        return switch (event) {
            case Matched matched -> false;
            case DriverArrived arrived -> false;
            case TripStarted started -> false;
            case TripCompleted completed -> true;
            case RiderCancelled cancelled -> true;
            // ADDED. The trip goes back to REQUESTED and keeps accepting events.
            case DriverCancelled cancelled -> false;
        };
    }

    private Rides() {}
}

worked/src-with-sixth-event/Transition.java19 lines

import java.util.Objects;

/**
 * One row of a trip's history: an accepted event and what it did. From
 * {@code corpus/trip-state-machine/contract/Transition.java}, with the clock dropped because
 * nothing here reads it.
 *
 * @param from  the state the trip was in when the event arrived
 * @param to    the state it was in immediately afterwards
 * @param event the event that caused it
 */
record Transition(TripState from, TripState to, TripEvent event) {

    Transition {
        Objects.requireNonNull(from, "from");
        Objects.requireNonNull(to, "to");
        Objects.requireNonNull(event, "event");
    }
}

worked/src-with-sixth-event/TripCompleted.java7 lines

/**
 * The rider was dropped off.
 *
 * @param distanceMetres how far the trip ran, so a receipt has something to print
 */
record TripCompleted(long distanceMetres) implements TripEvent {
}

worked/src-with-sixth-event/TripEvent.java14 lines

/**
 * The same closed set as {@code ../src/TripEvent.java}, with {@code DriverCancelled} added. This is
 * {@code corpus/trip-state-machine/curveballs/01-driver-cancels/contract-delta/TripEvent.java}.
 *
 * <p>One word was added to the {@code permits} clause. A line-level diff charges 2 for that word,
 * because it sees the line removed and re-added, which is the same accounting
 * {@code corpus/file-system/curveballs/01-shortcuts} pays on its own {@code Node.java}.
 *
 * <p>That one word is the whole edit that produced the compiler worklist. {@code NOTES.md} quotes
 * the three errors it caused, in order.
 */
sealed interface TripEvent
        permits Matched, DriverArrived, TripStarted, TripCompleted, RiderCancelled, DriverCancelled {
}

worked/src-with-sixth-event/TripStarted.java3 lines

/** The rider is in the car and the wheels are turning. Carries nothing. */
record TripStarted() implements TripEvent {
}

worked/src-with-sixth-event/TripState.java19 lines

/**
 * Where a trip is right now. Trimmed from
 * {@code corpus/trip-state-machine/contract/TripState.java}.
 *
 * <p>An enum rather than a sealed hierarchy, and the two live side by side in one problem for a
 * reason worth stating. A state is a bare name: {@code MATCHED} means the same thing every time it
 * occurs, so there is nothing per-occurrence to carry. An event is not: two {@code Matched} events
 * name two different drivers. That is the whole of the sealed-versus-enum rule, and A1 owns the
 * enum side of it.
 */
enum TripState {

    REQUESTED,
    MATCHED,
    DRIVER_ARRIVED,
    IN_PROGRESS,
    COMPLETED,
    CANCELLED;
}

worked/src/DriverArrived.java9 lines

/**
 * The driver reached the pickup point.
 *
 * <p>Carries nothing. Which driver arrived is already known from the {@link Matched} before it, and
 * repeating it here would be a second place for one fact to be wrong. A variant carrying no data is
 * not an argument for an enum on its own — the sibling that carries data is what decides.
 */
record DriverArrived() implements TripEvent {
}

worked/src/LenientRides.java59 lines

import java.util.List;
import java.util.Optional;

/**
 * The same three methods as {@link Rides}, each with a {@code default} arm. Nothing else differs.
 *
 * <p>This is not a straw man. Every {@code default} here removes real repetition — four arms of
 * {@code driverOf} said {@code driver}, and three arms of {@code endsTheTrip} said {@code false}.
 * A reviewer who has not been bitten by this will ask you to write it this way, and today the two
 * classes agree on every one of the five events. {@code Main} prints that agreement.
 *
 * <p>What the {@code default} costs is not visible today, which is the whole difficulty. It is
 * visible in {@code ../src-with-sixth-event/}, where a sixth event exists. {@link Rides} refused to
 * compile until someone answered for it. This class compiled unchanged and answered wrongly.
 *
 * <p>The rule, and it has no exception worth the words: a switch over a sealed type never carries a
 * {@code default}. When an arm has no sensible answer, throw in it — {@code corpus/file-system}'s
 * {@code Nodes.kindOf} throws in exactly one arm for exactly that reason.
 */
final class LenientRides {

    static Optional<String> driverOf(List<Transition> log) {
        Optional<String> driver = Optional.empty();
        for (Transition entry : log) {
            driver = switch (entry.event()) {
                case Matched matched -> Optional.of(matched.driverId());
                default -> driver;
            };
        }
        return driver;
    }

    static String message(TripEvent event) {
        return switch (event) {
            case Matched matched -> "your driver " + matched.driverId() + " is on the way";
            case TripStarted started -> "you are on your way";
            case TripCompleted completed -> "you have arrived after "
                    + completed.distanceMetres() + "m";
            case RiderCancelled cancelled -> "your trip was cancelled: " + cancelled.reason();
            default -> "your driver is outside";
        };
    }

    /**
     * The third site, and the one that makes the honest point about {@code default}. Its fallback
     * happens to be the right answer for the sixth event too, so this method is correct in
     * {@code ../src-with-sixth-event/} by luck. A {@code default} is not always wrong. It is always
     * unchecked, and nobody chose that answer.
     */
    static boolean endsTheTrip(TripEvent event) {
        return switch (event) {
            case TripCompleted completed -> true;
            case RiderCancelled cancelled -> true;
            default -> false;
        };
    }

    private LenientRides() {}
}

worked/src/LooseEvent.java50 lines

/**
 * The enum alternative, written the way a competent engineer writes it under a clock: one tag, and
 * one nullable field per variant that needs data.
 *
 * <p>Worth being precise about what this does and does not lose, because the usual claim is wrong.
 * It does <b>not</b> lose exhaustiveness. A switch over {@code Kind} with all five constants and no
 * {@code default} is checked exactly as a switch over a sealed interface is, and adding
 * {@code DRIVER_CANCELLED} to the enum breaks the same three sites. {@code Main} block 4 shows the
 * error, so the enum is not the careless option.
 *
 * <p>What it loses is that the combinations mean anything. There are two components here that apply
 * to one {@code Kind} each, so the header describes 5 tags times 2 optional fields, and only five of
 * those shapes are a trip event. {@code Main} block 3 constructs a {@code TRIP_STARTED} carrying a
 * driver id and a cancellation reason, and nothing refuses it. Refusing it means a validation rule
 * per component in the compact constructor, and that rule is a thing someone has to remember to
 * update every time a tag is added.
 *
 * <p>Against {@code TripStarted}, which is {@code record TripStarted() implements TripEvent}: there
 * is no argument to pass, so the bad instance is unconstructable and there is no rule to write.
 *
 * <p><b>The rule.</b> Both forms give you a checked switch, so exhaustiveness is not the deciding
 * factor. Reach for an enum while every variant carries the same fields, which is usually none.
 * Switch to a sealed interface of records the moment two variants need different fields, because the
 * enum form then needs one nullable per variant and a validation rule to keep them apart.
 * {@code TripState} in this directory is the enum side of that rule and stayed an enum.
 */
record LooseEvent(LooseEvent.Kind kind, String driverId, String reason, long distanceMetres) {

    /** The tag. Five constants, matching the five permitted subtypes of {@link TripEvent}. */
    enum Kind { MATCHED, DRIVER_ARRIVED, TRIP_STARTED, TRIP_COMPLETED, RIDER_CANCELLED }

    /** A tag with no payload, for the three variants that need none. */
    static LooseEvent of(Kind kind) {
        return new LooseEvent(kind, null, null, 0L);
    }

    /**
     * Who the driver is, as an enum switch with no {@code default}.
     *
     * <p>Here to make the honest half of the comparison checkable: this is exhaustiveness-checked,
     * and it breaks on a sixth constant. What it cannot do is read {@code driverId} without
     * trusting that a {@code MATCHED} has one.
     */
    static String driverIdOf(LooseEvent event) {
        return switch (event.kind()) {
            case MATCHED -> event.driverId();
            case DRIVER_ARRIVED, TRIP_STARTED, TRIP_COMPLETED, RIDER_CANCELLED -> null;
        };
    }
}

worked/src/Matched.java11 lines

/**
 * Dispatch assigned a driver.
 *
 * <p>The component is why this hierarchy is sealed records and not an enum: {@code Matched} has to
 * say <i>which</i> driver, and an enum constant is a singleton with no room for a per-occurrence
 * value.
 *
 * @param driverId which driver was assigned; never null
 */
record Matched(String driverId) implements TripEvent {
}

worked/src/RiderCancelled.java7 lines

/**
 * The rider called the trip off.
 *
 * @param reason free text from the app, e.g. "driver too far". Never null
 */
record RiderCancelled(String reason) implements TripEvent {
}

worked/src/Rides.java69 lines

import java.util.List;
import java.util.Optional;

/**
 * Three dispatch sites over the closed set, none of them carrying a {@code default}.
 *
 * <p>Condensed from three real files in {@code corpus/trip-state-machine/reference/src}:
 * {@code TripProjection.driverOf}, {@code RiderNotification.message} and the terminal check
 * {@code Trip.handle} performs. They are together here so the count of dispatch sites is visible in
 * one screen, because that count is what closing the set is buying.
 *
 * <p>The absence of {@code default} in all three is the load-bearing part of this class. Add a
 * sixth permitted event and every one of these methods stops compiling, each named by {@code javac}
 * with a line number. {@code LenientRides} is the same three methods with a {@code default} arm, and
 * {@code Main} prints what the difference is worth.
 */
final class Rides {

    /**
     * Who the driver is, folded over the log.
     *
     * <p>Four of the five arms return {@code driver} unchanged, which reads like noise a
     * {@code default} would delete. It is the opposite. Because there is no default, whoever adds an
     * event has to answer "does this change who the driver is?" instead of getting a silent no.
     */
    static Optional<String> driverOf(List<Transition> log) {
        Optional<String> driver = Optional.empty();
        for (Transition entry : log) {
            driver = switch (entry.event()) {
                case Matched matched -> Optional.of(matched.driverId());
                case DriverArrived arrived -> driver;
                case TripStarted started -> driver;
                case TripCompleted completed -> driver;
                case RiderCancelled cancelled -> driver;
            };
        }
        return driver;
    }

    /** What the rider is told about one event. */
    static String message(TripEvent event) {
        return switch (event) {
            case Matched matched -> "your driver " + matched.driverId() + " is on the way";
            case DriverArrived arrived -> "your driver is outside";
            case TripStarted started -> "you are on your way";
            case TripCompleted completed -> "you have arrived after "
                    + completed.distanceMetres() + "m";
            case RiderCancelled cancelled -> "your trip was cancelled: " + cancelled.reason();
        };
    }

    /**
     * Whether this event leaves the trip over for good.
     *
     * <p>The third site, and the one whose wrong answer is worst. A trip wrongly reported as over
     * refuses every later event, so the rider is stuck rather than merely misinformed.
     */
    static boolean endsTheTrip(TripEvent event) {
        return switch (event) {
            case Matched matched -> false;
            case DriverArrived arrived -> false;
            case TripStarted started -> false;
            case TripCompleted completed -> true;
            case RiderCancelled cancelled -> true;
        };
    }

    private Rides() {}
}

worked/src/Transition.java19 lines

import java.util.Objects;

/**
 * One row of a trip's history: an accepted event and what it did. From
 * {@code corpus/trip-state-machine/contract/Transition.java}, with the clock dropped because
 * nothing here reads it.
 *
 * @param from  the state the trip was in when the event arrived
 * @param to    the state it was in immediately afterwards
 * @param event the event that caused it
 */
record Transition(TripState from, TripState to, TripEvent event) {

    Transition {
        Objects.requireNonNull(from, "from");
        Objects.requireNonNull(to, "to");
        Objects.requireNonNull(event, "event");
    }
}

worked/src/TripCompleted.java7 lines

/**
 * The rider was dropped off.
 *
 * @param distanceMetres how far the trip ran, so a receipt has something to print
 */
record TripCompleted(long distanceMetres) implements TripEvent {
}

worked/src/TripEvent.java22 lines

/**
 * Something that happened to a trip. Drawn from
 * {@code corpus/trip-state-machine/contract/TripEvent.java}, which is given to a candidate sealed
 * for the reason this lesson is about.
 *
 * <p>The set is closed. Today a trip is matched, the driver arrives, the trip starts, the trip
 * completes, or the rider calls it off. There is no sixth kind, and the {@code permits} clause is
 * where that claim is written down once instead of assumed in every switch.
 *
 * <p>The sentence that decided this, from {@code idea.md}: a trip event is one of five things, and
 * the next kind gets written by the trip team, in this file. Dispatch, the rider app and a partner
 * fleet all <i>send</i> events; none of them invents an event type. So the blank says "me, here",
 * and the set is sealed.
 *
 * <p>Compare {@code corpus/logger/contract/Appender.java}, which is an ordinary interface. A
 * destination for a log line is written by whoever has somewhere to put a line, which is a
 * different answer to the same blank. Its curveball 03 adds a rolling-file destination at
 * {@code reference_diff: 0}.
 */
sealed interface TripEvent
        permits Matched, DriverArrived, TripStarted, TripCompleted, RiderCancelled {
}

worked/src/TripStarted.java3 lines

/** The rider is in the car and the wheels are turning. Carries nothing. */
record TripStarted() implements TripEvent {
}

worked/src/TripState.java19 lines

/**
 * Where a trip is right now. Trimmed from
 * {@code corpus/trip-state-machine/contract/TripState.java}.
 *
 * <p>An enum rather than a sealed hierarchy, and the two live side by side in one problem for a
 * reason worth stating. A state is a bare name: {@code MATCHED} means the same thing every time it
 * occurs, so there is nothing per-occurrence to carry. An event is not: two {@code Matched} events
 * name two different drivers. That is the whole of the sealed-versus-enum rule, and A1 owns the
 * enum side of it.
 */
enum TripState {

    REQUESTED,
    MATCHED,
    DRIVER_ARRIVED,
    IN_PROGRESS,
    COMPLETED,
    CANCELLED;
}

worked/src-with-sixth-event/Main.java49 lines

import java.util.List;

/**
 * The sixth variant exists. Block 2 is the whole lesson: the same log, read by the strict dispatch
 * and by the lenient one.
 *
 * <pre>
 * ..\..\..\..\.toolchain\jdk-21\bin\javac.exe -Xlint:all -d out *.java
 * ..\..\..\..\.toolchain\jdk-21\bin\java.exe -cp out Main
 * </pre>
 */
public final class Main {

    /** Matched to d-7, the driver arrives, then the driver backs out. The trip is still live. */
    private static final List<Transition> LOG = List.of(
            new Transition(TripState.REQUESTED, TripState.MATCHED, new Matched("d-7")),
            new Transition(TripState.MATCHED, TripState.DRIVER_ARRIVED, new DriverArrived()),
            new Transition(TripState.DRIVER_ARRIVED, TripState.REQUESTED,
                    new DriverCancelled("d-7", "too far")));

    public static void main(String[] args) {
        System.out.println("--- 1. the set is one word wider");
        System.out.printf("  permits count                  : %d%n",
                TripEvent.class.getPermittedSubclasses().length);

        System.out.println("--- 2. the same log, read two ways");
        DriverCancelled cancelled = new DriverCancelled("d-7", "too far");
        System.out.printf("  driverOf   strict              : %s%n", Rides.driverOf(LOG));
        System.out.printf("  driverOf   lenient             : %s%n", LenientRides.driverOf(LOG));
        System.out.printf("  message    strict              : %s%n", Rides.message(cancelled));
        System.out.printf("  message    lenient             : %s%n", LenientRides.message(cancelled));
        System.out.printf("  endsTheTrip strict             : %s%n", Rides.endsTheTrip(cancelled));
        System.out.printf("  endsTheTrip lenient            : %s%n", LenientRides.endsTheTrip(cancelled));

        System.out.println("--- 3. scored");
        System.out.println("  driverOf    lenient names a driver who has gone home");
        System.out.println("  message     lenient tells the rider a departed driver is outside");
        System.out.println("  endsTheTrip lenient is right, by luck, and nobody chose it");
        System.out.println("  strict could not be built until all three were answered");

        System.out.println("--- 4. the enum form got the same worklist");
        LooseEvent loose = new LooseEvent(LooseEvent.Kind.DRIVER_CANCELLED, "d-7", "too far", 0L);
        System.out.printf("  driverIdOf(DRIVER_CANCELLED)   : %s%n", LooseEvent.driverIdOf(loose));
        System.out.printf("  still constructable, still odd : %s%n",
                new LooseEvent(LooseEvent.Kind.TRIP_STARTED, "d-7", "changed my mind", 0L));
    }

    private Main() {}
}

worked/src/Main.java66 lines

import java.util.List;

/**
 * Prints the four facts this lesson rests on. Run it, then run
 * {@code ../src-with-sixth-event/Main} and compare block 2 of each.
 *
 * <p>Compile and run from this directory:
 *
 * <pre>
 * ..\..\..\..\.toolchain\jdk-21\bin\javac.exe -Xlint:all -d out *.java
 * ..\..\..\..\.toolchain\jdk-21\bin\java.exe -cp out Main
 * </pre>
 */
public final class Main {

    private static final List<TripEvent> TODAYS_FIVE = List.of(
            new Matched("d-7"),
            new DriverArrived(),
            new TripStarted(),
            new TripCompleted(4200L),
            new RiderCancelled("driver too far"));

    public static void main(String[] args) {
        System.out.println("--- 1. the closed set, as the JVM sees it");
        System.out.printf("  isSealed()                     : %s%n", TripEvent.class.isSealed());
        StringBuilder names = new StringBuilder();
        for (Class<?> permitted : TripEvent.class.getPermittedSubclasses()) {
            names.append(names.isEmpty() ? "" : ", ").append(permitted.getSimpleName());
        }
        System.out.printf("  permits                        : %s%n", names);
        System.out.printf("  dispatch sites over it in Rides: %d (driverOf, message, endsTheTrip)%n", 3);

        System.out.println("--- 2. strict and lenient agree on every event that exists today");
        for (TripEvent event : TODAYS_FIVE) {
            String label = event.getClass().getSimpleName();
            boolean sameMessage = Rides.message(event).equals(LenientRides.message(event));
            boolean sameEnd = Rides.endsTheTrip(event) == LenientRides.endsTheTrip(event);
            System.out.printf("  %-15s message same: %-5s  endsTheTrip same: %s%n",
                    label, sameMessage, sameEnd);
        }
        List<Transition> log = List.of(
                new Transition(TripState.REQUESTED, TripState.MATCHED, new Matched("d-7")),
                new Transition(TripState.MATCHED, TripState.DRIVER_ARRIVED, new DriverArrived()));
        System.out.printf("  driverOf strict                : %s%n", Rides.driverOf(log));
        System.out.printf("  driverOf lenient               : %s%n", LenientRides.driverOf(log));
        System.out.println("  so nothing here argues for either, and that is the difficulty");

        System.out.println("--- 3. what the enum-plus-nullable-fields form permits");
        LooseEvent nonsense = new LooseEvent(LooseEvent.Kind.TRIP_STARTED, "d-7", "changed my mind", 0L);
        System.out.printf("  constructed                    : %s%n", nonsense);
        System.out.printf("  driverIdOf says                : %s%n", LooseEvent.driverIdOf(nonsense));
        System.out.printf("  and the trip has no driver yet : the id is carried and never read%n");
        System.out.printf("  the sealed form of the same    : %s%n", new TripStarted());
        System.out.println("  new TripStarted(\"d-7\") does not compile: constructor cannot be applied");

        System.out.println("--- 4. the enum form is still exhaustiveness-checked");
        System.out.printf("  driverIdOf(MATCHED)            : %s%n",
                LooseEvent.driverIdOf(new LooseEvent(LooseEvent.Kind.MATCHED, "d-9", null, 0L)));
        System.out.printf("  driverIdOf(TRIP_COMPLETED)     : %s%n",
                LooseEvent.driverIdOf(LooseEvent.of(LooseEvent.Kind.TRIP_COMPLETED)));
        System.out.println("  adding DRIVER_CANCELLED to Kind breaks this switch too, so the enum");
        System.out.println("  is not the careless choice. NOTES.md quotes both errors side by side");
    }

    private Main() {}
}

The faded stage is not here, on purpose

In the app, the third stage of a lesson hands you the worked design with a few lines replaced by // GAP: markers, then compiles your completion and runs a JUnit suite against it. That needs javac, and a static site has no compiler — so rather than show a control that cannot work, this page stops at the worked source.

Run the app for the drill: it is the download in the header, and it works offline once unpacked.

← A2 · Value object / record, immutable by construction A4 · Entity vs value; identity and the equals/hashCode contract →

← all lessons