LLD Dojo

Java Bridge · J11

Modern Java a C++ dev has not met — sealed, record, switch patterns, and the rest

The idea

The variant set the compiler keeps checking

corpus/file-system has two kinds of thing that can sit at a path, and one method that asks which kind it got. Written the way you would write it first:

if (node instanceof FileNode) {
    return EntryKind.FILE;
}
return EntryKind.DIRECTORY;

That works for exactly as long as there are two kinds. Then curveball 01 arrives: add shortcuts. Now there are three, and the code above still compiles clean and reports a shortcut as a DIRECTORY. Nothing warns you, in that file or any other.

std::variant with std::visit is the tool you already have for this, and it does check. Java's version drops the visitor and keeps the check. The set is named once:

sealed interface Node permits FileNode, DirectoryNode {}

Add SymlinkNode to that permits list and change nothing else:

Nodes.java:14: error: the switch expression does not cover all possible input values
        return switch (node) {
               ^
1 error

One error, at the file and line that now has a decision to make.

The third piece is record. A shortcut is nothing but the path it points at, so its declaration is record SymlinkNode(String target) implements Node {}, with equals and hashCode arriving inside it. Eight lines in the corpus become one.

So the three features are one technique. The seal closes the set, records make its members cheap to write, and the switch is where the compiler cashes the promise in.

Reach for it when you can name every member today and want to be told the day that stops being true. var, Optional and text blocks are in from-cpp.md.


Coming from C++

From C++ — std::variant without the visitor, and five smaller mappings

Every concept on this page is one you have. std::variant, std::visit, std::optional, auto, raw string literals. What is worth your time is the mapping, plus the four places where Java's version behaves differently enough to change a design.

The first section is most of the lesson. The rest are one-screen mappings.

The closed variant set

corpus/file-system has a tree whose nodes are files or directories, and there is no third kind. In C++ that is a variant with two alternatives.

C++

struct Node;                       // the variant, forward declared for the recursion

struct FileNode      { std::string content; };
struct DirectoryNode { std::map<std::string, std::unique_ptr<Node>> children; };

struct Node { std::variant<FileNode, DirectoryNode> value; };

EntryKind kindOf(const Node& node) {
    return std::visit(overloaded{
        [](const FileNode&)      { return EntryKind::File; },
        [](const DirectoryNode&) { return EntryKind::Directory; },
    }, node.value);
}

Javacorpus/file-system/reference/src/Node.java and Nodes.java, verbatim apart from the comments:

sealed interface Node permits FileNode, DirectoryNode {}

final class FileNode implements Node { … }
final class DirectoryNode implements Node { … }

static EntryKind kindOf(Node node) {
    return switch (node) {
        case FileNode file -> EntryKind.FILE;
        case DirectoryNode dir -> EntryKind.DIRECTORY;
    };
}

Line up what each half is doing. The permits clause is the alternative list, and in both languages it is the one place the set is written down. std::visit refuses a visitor that misses an alternative; the switch refuses a case list that misses a permitted type. Same guarantee, from the same declaration.

Three things went away. The overloaded helper, which is boilerplate you write once per project and explain to every new hire. The visitor object itself. And std::get_if with its pointer result, because case FileNode file binds a typed file with no cast written anywhere.

One thing that also went away, and it is worth noticing: the forward-declared struct Node wrapper. A std::variant needs its alternatives to be complete types, so a recursive variant needs an indirection you insert by hand. A Java interface reference is already an indirection, so DirectoryNode holding a TreeMap<String, Node> needs nothing.

The check, and the day it fires

Curveball 01 of file-system asks for shortcuts: a name in the tree that points at another path. That is a third kind of node where the problem said there were two.

Take the real reference solution, add SymlinkNode to the permits clause, add the new file, and change nothing else. One command, real output:

Nodes.java:14: error: the switch expression does not cover all possible input values
        return switch (node) {
               ^
1 error

One error, in the one file whose job is to know. javac names the line, and the fix is the case it demanded:

            case SymlinkNode link -> throw new IllegalStateException(
                    "PathResolver must never return an unresolved shortcut");

Read that arm carefully, because it is the part people find strange. The case cannot happen — resolution follows shortcuts, so an unresolved one never reaches here. The compiler made you write a decision for an impossible state anyway, and the decision is recorded rather than assumed.

What the whole curveball cost, measured. curveballs/01-shortcuts/budget.json records reference_diff = 28, and reference-patch/PATCH.md breaks it down: TreeResolver.java 16 lines, InMemoryFileSystem.java 8, Node.java 2, Nodes.java 2.

Four of the 28 belong to the sealed hierarchy. Node.java's two are one word added to a permits list, charged as a removal and a re-addition because a line-level diff sees the line rewritten. Nodes.java's two are the forced case.

The other 24 are the requirement itself. Seven of the eight operations changed by zero lines, because they were written in terms of PathResolver rather than a map they walked themselves. So the sealed type is not what made this change cheap. It is what made the change complete — the compiler produced the list of places that had a decision to make, and the list had one entry.

The three ways to lose the check

A default arm. Same reference, same edit, but kindOf written with two cases and a default:

        return switch (node) {
            case FileNode file -> EntryKind.FILE;
            default -> EntryKind.DIRECTORY;
        };

Adding the variant now compiles clean. Real output from running it:

kindOf(new SymlinkNode("/logs/app.log")) = DIRECTORY

A shortcut is reported as a directory. Nothing threw, nothing warned, and kindOf is what a caller uses to decide whether to call read or ls.

An instanceof chain. This is the version a competent engineer writes before meeting sealed types, and it is what J5's Appender hierarchy would have forced:

        if (node instanceof FileNode) {
            return EntryKind.FILE;
        }
        return EntryKind.DIRECTORY;

Also compiles clean after the edit, also answers DIRECTORY. Measured the same way, same output.

A visitor with a generic parameter. The C++ half has the identical hole:

std::visit([](auto&&) { return EntryKind::Directory; }, node.value);

So this is not a Java lesson about C++ being careless. It is the same trade in both languages: a catch-all arm buys you silence today and costs you the compile error you wanted.

The rule that follows is short. A switch over a sealed type never has a default. If you cannot answer a case, throw in it, the way Nodes.kindOf does.

Guards, deconstruction, and the two orderings that matter

The switch does more than one test per case. worked/src/Nodes.java has five answers over three variants:

        return switch (node) {
            case FileNode file when file.content().isEmpty() -> "an empty file";
            case FileNode file -> "a file of " + file.content().length() + " characters";
            case DirectoryNode dir when dir.childCount() == 0 -> "an empty directory";
            case DirectoryNode dir -> "a directory of " + dir.childCount() + " entries";
            case SymlinkNode(String target) -> "a shortcut to " + target;
        };

when is a guard on the case, so a condition about a variant lives beside the variant instead of in a nested if. case SymlinkNode(String target) is a record deconstruction pattern: it matches the type and binds the component in one go, with no accessor call.

Two ordering rules, both enforced. A guarded case must come before the unguarded case for the same type, because the unguarded one covers everything:

T9.java:5: error: this case label is dominated by a preceding case label
            case FileNode f -> "file";
                 ^
1 error

And a guard never counts towards completeness. Drop the two unguarded arms and the switch is incomplete again, because javac will not reason about whether isEmpty() always holds:

TA.java:3: error: the switch expression does not cover all possible input values
        return switch (node) {
               ^
1 error

case null, which C++ has no need for

A variant always holds something. A Java reference does not, so a pattern switch has to say what it does about null. The default behaviour is to throw:

Nodes.describe(null)
java.lang.NullPointerException

That is usually what you want, and it is why Nodes.kindOf has no null case. When null is a legitimate input, say so, and the switch stops throwing. worked/src/MiniFileSystem.write is that case, because parent.child(leaf) returns null for a name that is not there yet:

        switch (parent.child(leaf)) {
            case null -> parent.addChild(leaf, new FileNode(content));
            case FileNode file -> file.write(content);
            case DirectoryNode dir -> throw new IsADirectoryException(path);
            case SymlinkNode link -> throw new IllegalStateException(…);
        }

Those four arms are curveball 01's real fix to write, and PATCH.md charges 4 of its 8 lines in that file to it.

When a sealed hierarchy is the wrong answer

Two cases, and both come up in the corpus.

The variants carry no data. Then it is an enum, and an enum switch is checked the same way. EntryKind in this very problem is an enum for exactly that reason. J10 is enums.

You want other people to add variants. A plugin point, a policy interface, a strategy — anything where a second team's class is meant to slot in. Appender in corpus/logger is an ordinary interface for that reason, and sealing it would break the curveball that adds a rolling destination. J5's threshold list ends here on purpose: the question is whether you know every implementor and want to keep knowing.

corpus/trip-state-machine/contract/TripEvent.java is the other strong case in the corpus, and its javadoc makes the argument in one paragraph worth stealing for an interview. Its events carry data that differs per occurrence: Matched names a driver, RiderCancelled names a reason. An `enum Kind plus a nullable driverId and a nullable reason would permit a TRIP_STARTED` carrying a driver id. With five records behind a sealed interface, that state is unconstructable, so nothing validates it.

record

SymlinkNode in the corpus is a final class with one final field, one constructor and one accessor. Eight non-blank lines. As a record it is one:

record SymlinkNode(String target) implements Node {}

That header generates five things. A canonical constructor taking the components in order. An accessor per component, named after it. equals comparing every component, and a hashCode consistent with that equals. And a toString naming the class and each component. Block 2 of worked/ prints what arrived:

--- 2. what the record header generated
  Listing.class.isRecord()       : true
  components                     : String path, EntryKind kind, int childCount
  toString                       : Listing[path=/logs, kind=DIRECTORY, childCount=3]
  accessor one.childCount()      : 3
  one == two                     : false
  one.equals(two)                : true
  hashCodes agree                : true
  List.of(one).contains(two)     : true

one == two is false and one.equals(two) is true, for two separately constructed values with identical components. That is J6's trap, and a record is the one declaration in Java that cannot fall into it. List.of(one).contains(two) returning true is the same fact from the caller's side.

Say what a record is not

It is not a struct. Its components are final, and there is no way to add a non-static field:

T6.java:2: error: cannot assign a value to final variable childCount
    void bump() { childCount = childCount + 1; }
                  ^
1 error
T7.java:2: error: field declaration must be static
    private int hits;
                ^
  (consider replacing field with record component)
1 error

The second message is the useful one. javac tells you the only place state may go in a record: the header.

It is not a base class and it has no base class. A record extends java.lang.Record already, so the grammar has no room for another:

T8.java:2: error: '{' expected
record R(String s) extends Base {}
                  ^
1 error

It can implement any number of interfaces, which is what makes the sealed hierarchy work.

Final components are not deep immutability. A record holding a List hands the same list to every caller through the generated accessor, and there is no copy. That is A7's subject, and it is why DirectoryNode in worked/src is a class rather than a record with a TreeMap component.

The compact constructor

This is where validation and normalisation go, and it is the piece with no C++ counterpart. The form drops the parameter list and the assignments:

record SymlinkNode(String target) implements Node {

    SymlinkNode {
        Objects.requireNonNull(target, "target");
        if (!target.startsWith("/")) {
            throw new IllegalArgumentException("a shortcut target must be absolute: " + target);
        }
        if (target.length() > 1 && target.endsWith("/")) {
            target = target.substring(0, target.length() - 1);
        }
    }
}

The generated constructor assigns the parameters to the fields after this body runs. So target is still an ordinary local here, and reassigning it changes what the field ends up holding. That last if is the only legal place in a record to normalise a component.

Block 3 of worked/ runs both jobs:

--- 3. the compact constructor, doing the two jobs it exists for
  "/logs/" normalised to         : /logs
  equal to the version without   : true
  a negative child count         : java.lang.IllegalArgumentException: childCount must not be negative: -1
  a relative path                : java.lang.IllegalArgumentException: path must be absolute: logs
  new SymlinkNode("/logs/")      : SymlinkNode[target=/logs]
  equal to the version without   : true
  a null target                  : java.lang.NullPointerException: target

Normalising once, here, is what makes equals mean what a reader expects. Two spellings of one path are one value. Skip it and every comparison in the program has to know that /logs/ and /logs are the same place.

J4 is where the general construction rules live: final fields, no initialiser lists, static factories. A record is those rules with the typing done for you. The compact constructor is J4's "validate before the object exists", moved to the one place every caller passes through. corpus/trip-state-machine/contract/Transition.java is four requireNonNull calls in a compact constructor and nothing else.

The corpus has 88 record declarations and four sealed hierarchies: Node in file-system, TripEvent in trip-state-machine, SquareEffect in snake-and-ladder, and Segment in middleware-router. Records are ordinary here. Sealing is a decision you make four times in twenty problems.

var

auto and var are both local type inference, and auto goes to more places. That asymmetry is the whole delta.

var is legal for a local variable with an initialiser, a for loop variable, a for-each variable, a try-with-resources resource, and a lambda parameter. Nowhere else, and the message is the same each time:

T3.java:2: error: 'var' is not allowed here
    var field = 1;
    ^
T3.java:3: error: 'var' is not allowed here
    static int f(var x) { return x; }
                 ^
T3.java:4: error: 'var' is not allowed here
    static var g() { return 1; }
           ^
T3.java:8: error: 'var' is not allowed as an element type of an array
        var[] c = new int[3];
        ^
4 errors

No field, no method parameter, no return type, no array element type, and no catch parameter. In C++ a deduced return type and an auto parameter are both ordinary; in Java a signature always states its types. That is a language decision rather than an omission: a signature is the part other files read.

Two more refusals, and both are the same rule from two sides:

T4.java:3: error: cannot infer type for local variable a
        var a = null;
            ^
  (variable initializer is 'null')
T5.java:3: error: cannot infer type for local variable b
        var b;
            ^
  (cannot use 'var' on variable without initializer)

The threshold. Use var when the right-hand side already names the type, and write the type out when it does not. var rows = new ArrayList<Listing>() says ArrayList<Listing> twice, so one of the two is noise. var found = fs.find("/logs/latest") says nothing about what was found, and the reader has to open another file. Block 5 of worked/ is that pair:

--- 5. var, where the right-hand side already names the type
  var rows = new ArrayList<Listing>() : ArrayList, holding 1
  var row  = new Listing(..)           : Listing
  Optional<Node> found = fs.find(..)   : Optional is all 'var found' would have told a reader

The corpus agrees with that threshold by using var three times across 638 Java files. All three sit in corpus/splitwise/curveballs/01-weighted-shares/tests/, and all three are assigned from a call whose name carries the type. var is not a style you owe anyone. In a twelve-minute skeleton sprint it saves you retyping Map<String, List<Transition>>, and that is the case for it.

Optional

std::optional<T> maps to Optional<T> for the one thing that matters: a return value that may have nothing in it, with the emptiness in the type instead of in a comment. corpus/lru-cache's get returns Optional<Object>, and corpus/library's three repositories return Optional<Title>, Optional<Loan> and Optional<Member>. There are 64 occurrences across the corpus outside comments. Not one of them is a method parameter, and not one is an ordinary field.

Three differences, in order of how much trouble they cause.

An Optional is an object on the heap, and the reference can itself be null. std::optional<T> is a T plus a flag, stored inline, and it cannot not-exist. Java's cannot make that promise, and block 6 of worked/ shows the failure:

  an Optional reference is itself nullable:
    java.lang.NullPointerException: Cannot invoke "java.util.Optional.isPresent()" because "Main.cached" is null

Three states where C++ has two: null, empty, present. That third state is the argument against declaring one as a field.

get() is a null dereference wearing a different name. No check, no warning, and the message names nothing:

  get() on the empty one         : java.util.NoSuchElementException: No value present

std::optional::operator* has the same hazard and you already route around it. The Java habit is to use the three methods that force the decision, all shown in block 6:

  find(/logs/latest)             : Optional[SymlinkNode[target=/logs/app.log]]
  find(/var/missing.log)         : Optional.empty
  map then orElse                : nothing is there
  orElseThrow, which get(path) is: NoSuchPathException: /var/missing.log

orElseThrow(supplier) builds the exception only when there is nothing there, and the exception can name the path. That one line is the whole body of MiniFileSystem.get:

    Node get(String path) {
        return find(path).orElseThrow(() -> new NoSuchPathException(path));
    }

map is where a chain of "if it is there, then" collapses. orElse supplies the fallback. If your code calls isPresent() and then get(), you have written an if (p != null) with more ceremony.

It is a return type, not a field type and not a parameter type. This is convention rather than compiler enforcement, and the reasons are concrete. Optional is not Serializable. A field of that type has the three states above. A parameter of that type makes every caller wrap a value it already has, where an overload or a documented null costs nothing.

The corpus has two Optionals in a field position, both in corpus/tic-tac-toe/contract/, and both are instructive rather than contradictions. MoveResult is a record returned from play() with an Optional<Mark> winner component. GameState is a record returned from state() with the same component plus a List<Optional<Mark>> cells.

Look at what each compact constructor does first. MoveResult calls Objects.requireNonNull(winner, "winner"), which removes the third state, and then enforces that winner is present exactly when the status is WON. GameState does the same in four requireNonNull calls. A component of a returned value is still part of a return type, and both of them pay for the privilege with a null check.

Text blocks

""" is Java's raw string literal, and it does the two jobs you use R"(...)" for: no escaping, and real line breaks. It adds one thing C++ does not have, which is incidental indentation stripping. The common leading whitespace across all lines, including the closing delimiter's line, is removed.

That makes it useful for one thing in this project: a multi-line expected value in a test, written at the indentation of the code around it. Block 7 of worked/:

        String expected = """
                /logs/app.log   FILE      0
                /logs/empty.log FILE      0
                /logs/latest    FILE      0
                """;
--- 7. a text block holding the expected listing
  actual.equals(expected)        : true

Two mechanical points. The opening """ must be followed by a line break, and the error if it is not names the problem:

TC.java:2: error: illegal text block open delimiter sequence, missing line terminator
    static final String S = """/a
                               ^

And the closing delimiter's own line decides whether there is a trailing newline. Put """ on its own line and the string ends with \n; put it at the end of the last content line and it does not.

That is the whole feature. It is a convenience for test fixtures and prompt strings, and no design decision turns on it.

The delta table

C++JavaWatch for
std::variant<A, B>sealed interface T permits A, Bthe seal is on the interface, not at the use site
the alternative listthe permits clauseboth are the one place the set is written
std::visit with a full visitorswitch over patterns, no defaultsame completeness guarantee
overloaded{...} helpernothing to writethe case list is the visitor
std::get_if<A>(&v)case A a ->, or if (v instanceof A a)the pattern binds; no cast appears
std::visit with auto&&a default armboth silence the check
std::holds_alternative<A>instanceof Ano index, no bad_variant_access
variant stored inline, size of largesta reference, always heapno value semantics, no copy cost
the same type twice, by indeximpossible; a type is its own name
recursive variant needs an indirectiona reference is one alreadyno wrapper struct
struct with public membersrecord, and the components are finalnot a struct; no non-static field allowed
a constructor body that validatesthe compact constructorthe only place a component can be reassigned
operator== you wrotegenerated equals over all componentsJ6's trap cannot happen here
auto on locals, parameters, return typesvar on locals onlya signature always states its types
auto x = ...; as a class membernot allowed eithervar field is 'var' is not allowed here
std::optional<T> inline, cannot be absentOptional<T> on the heap, can be nullthree states, not two
*opt with no checkopt.get() with no checkNoSuchElementException: No value present
opt.value_or(x)orElse(x), or orElseGet(supplier)orElse evaluates its argument always
opt.value() throwing bad_optional_accessorElseThrow(() -> yourException)yours can name the path
std::optional as a member, commonan Optional field, avoidednot Serializable, and the third state
R"(raw)"""" text blockmust break the line after the opening delimiter
no indentation handlingincidental indentation strippedthe closing delimiter's line sets the margin

Under a clock

The grading angle in one line, because it changes what you type in the first three minutes of a drill. A record beats a hand-written value class every time. It is one line against eight, and it cannot get equals wrong.

sealed plus a switch is the same argument at design scale. When the interviewer says "now add a symlink," you add one word to permits and compile. The list of places that need a decision comes back from the compiler.


Worked walkthrough

NOTES — twelve files, one closed set, and the two switches that check it

Run it first

From the directory holding the sources:

..\..\..\.toolchain\jdk-21\bin\javac.exe -d out *.java
..\..\..\.toolchain\jdk-21\bin\java.exe -cp out Main

Real output, verbatim:

--- 1. the closed set, and what the compiler knows about it
  Node.class.isSealed()          : true
  permitted implementations      : FileNode, DirectoryNode, SymlinkNode
  kindOf(/logs)                  : DIRECTORY
  kindOf(/logs/app.log)          : FILE
  kindOf(/logs/latest)           : FILE  (the shortcut was followed)
  kindOf on a raw shortcut       : java.lang.IllegalStateException: resolve() must never hand back an unresolved shortcut: /logs/app.log
--- 2. what the record header generated
  Listing.class.isRecord()       : true
  components                     : String path, EntryKind kind, int childCount
  toString                       : Listing[path=/logs, kind=DIRECTORY, childCount=3]
  accessor one.childCount()      : 3
  one == two                     : false
  one.equals(two)                : true
  hashCodes agree                : true
  List.of(one).contains(two)     : true
  FileNode.class.isRecord()      : false  (its content changes after construction)
--- 3. the compact constructor, doing the two jobs it exists for
  "/logs/" normalised to         : /logs
  equal to the version without   : true
  a negative child count         : java.lang.IllegalArgumentException: childCount must not be negative: -1
  a relative path                : java.lang.IllegalArgumentException: path must be absolute: logs
  new SymlinkNode("/logs/")      : SymlinkNode[target=/logs]
  equal to the version without   : true
  a null target                  : java.lang.NullPointerException: target
--- 4. one switch, five answers, no casts
  describe(/logs)                : a directory of 3 entries
  describe(/var)                 : an empty directory
  describe(/logs/app.log)        : a file of 36 characters
  describe(/logs/empty.log)      : an empty file
  describe(/logs/latest)         : a shortcut to /logs/app.log
  describe(null)                 : java.lang.NullPointerException
--- 5. var, where the right-hand side already names the type
  var rows = new ArrayList<Listing>() : ArrayList, holding 1
  var row  = new Listing(..)           : Listing
  Optional<Node> found = fs.find(..)   : Optional is all 'var found' would have told a reader
--- 6. Optional, as a return type and nothing else
  find(/logs/latest)             : Optional[SymlinkNode[target=/logs/app.log]]
  find(/var/missing.log)         : Optional.empty
  map then orElse                : nothing is there
  get() on the empty one         : java.util.NoSuchElementException: No value present
  orElseThrow, which get(path) is: NoSuchPathException: /var/missing.log
  an Optional reference is itself nullable:
    java.lang.NullPointerException: Cannot invoke "java.util.Optional.isPresent()" because "Main.cached" is null
--- 7. a text block holding the expected listing
  actual.equals(expected)        : true
  the expected block, as stored, three lines and one trailing newline:
    /logs/app.log   FILE      0
    /logs/empty.log FILE      0
    /logs/latest    FILE      0
--- 8. a shortcut that points at itself
  read(/tmp/loop)                : java.lang.IllegalStateException: shortcut loop reached from /tmp/loop
  find(/tmp/loop) does not follow: Optional[SymlinkNode[target=/tmp/loop]]

Four lines in there are the lesson. permitted implementations in block 1, the pair one == two : false and one.equals(two) : true in block 2, and `describe(/logs/empty.log) : an empty file` in block 4, which is a guard rather than a variant.

javac -Xlint:all prints three warnings and no errors, all three the same one:

NoSuchPathException.java:5: warning: [serial] serializable class NoSuchPathException has no definition of serialVersionUID
final class NoSuchPathException extends RuntimeException {
      ^

Every RuntimeException subclass in the corpus draws that warning too, and none of them declares the field. It is not part of this lesson, and it is here rather than silently suppressed.

out/ holds twelve class files from twelve sources, with no $1 or $2 among them. The two lambdas in Main and the one in MiniFileSystem.get produce no files, for the reason J5's notes give: they are spun up at run time from an invokedynamic call site.


Node.java — one line, and the only line that closes the set

sealed interface Node permits FileNode, DirectoryNode, SymlinkNode {
}

Both words carry a guarantee, and they are different guarantees.

sealed promises that the implementations are known at compile time. What that buys is the completeness check on every switch over a Node, anywhere in the program. Drop sealed and both switches in Nodes.java stop compiling until each grows a default, and a default is what makes a missing case silent.

permits is the list the check is measured against. It is also refused when it is absent, because the implementations are in other files:

Node.java:1: error: sealed class must have subclasses
sealed interface Node {
       ^

What breaks without the seal, concretely: a class in any other file can implement Node. Then the set is open, Nodes.kindOf needs a fallback, and the fallback answers for a node nobody has thought about yet. Block 1 prints Node.class.isSealed() : true and the three names, which is the promise as the JVM sees it.

The cost, stated plainly. Every new variant is an edit to this file, so a team that wants to add node kinds without touching your code cannot. That is the trade, and it is the same one std::variant makes.


FileNode.java and DirectoryNode.java — the two that are not records

final class FileNode implements Node {
    private String content;
    …
    void write(String content) {
        this.content = content;
    }
}

write is the whole reason this is a class. A record's components are final, so the record form would make every write build a new FileNode and rebind it in its parent directory. Block 2 prints FileNode.class.isRecord() : false for that reason and no other.

DirectoryNode is a class for a second reason worth separating. Its state is a TreeMap, and a record's generated accessor returns the component itself, with no copy. A record DirectoryNode(TreeMap<String, Node> children) would hand the live map to every caller. Then addChild's invariant, no two children sharing a name, would be enforceable by nobody.

final on both classes is required, not stylistic. A permitted subclass of a sealed type has to be final, sealed or explicitly non-sealed:

T2.java:2: error: sealed, non-sealed or final modifiers expected
class Circle implements Shape {}
^

That message is the one people find puzzling. The sealed type's promise is about the whole hierarchy, so an open leaf would reopen the set through the back door.


SymlinkNode.java — the record, and the constructor that earns it

record SymlinkNode(String target) implements Node {

One line replaces eight, and the eight are what corpus/file-system/curveballs/01-shortcuts/reference-patch/SymlinkNode.java actually contains: a field, a constructor, an accessor and their braces. The record also arrives with equals, hashCode and toString that the class version never had, and block 3 prints the toString as SymlinkNode[target=/logs].

    SymlinkNode {
        Objects.requireNonNull(target, "target");

The invariant: no SymlinkNode exists anywhere with a null target. So no reader of target() checks for null, and follow in MiniFileSystem can call get(link.target()) without a guard. Drop this line and the null is discovered at the first resolution, several calls away from the constructor that allowed it.

        if (!target.startsWith("/")) {
            throw new IllegalArgumentException("a shortcut target must be absolute: " + target);
        }

A relative target can never resolve, because MiniFileSystem only walks from the root. Refusing it here turns a permanent lookup failure into a construction failure with the bad value in the message. Without it, the symptom is NoSuchPathException for a path that reads as though it should exist.

        if (target.length() > 1 && target.endsWith("/")) {
            target = target.substring(0, target.length() - 1);
        }

This assignment is legal only here, and only because of when the compact constructor runs. The generated constructor assigns the parameters to the fields after this body finishes, so target is an ordinary local until then. Try the same assignment in any other method and:

T6.java:2: error: cannot assign a value to final variable childCount

What it guarantees: new SymlinkNode("/logs/") and new SymlinkNode("/logs") are equals and share a hashCode, which block 3 prints as two true lines. What breaks without it: two records naming one path are unequal, land in different HashMap buckets, and describe reports a shortcut to /logs/ where the rest of the program says /logs.


Listing.java — the record as a returned value

record Listing(String path, EntryKind kind, int childCount) {

Three components, and equals compares all three. This is the row ls hands back, and its whole job is to be compared, printed and put in lists. Block 2 is the J6 pair on one object: one == two is false and one.equals(two) is true.

List.of(one).contains(two) : true is the same fact from the side that bites in production. contains calls equals, so a hand-written value class with no equals answers false for a row that is in the list. That is J6's failure, and a record cannot produce it.

    Listing {
        Objects.requireNonNull(path, "path");
        …
        if (childCount < 0) {

Every constructor call passes through here, because a record has exactly one canonical constructor. That is what makes it the right place for the check, and it is J4's rule about validating before the object exists.

The negative-count check is not defensive noise. childCount is read by isEmptyDirectory, and a negative value would make that method answer false for a directory with impossible state, rather than throwing where the impossible value was introduced.

    boolean isEmptyDirectory() {
        return kind == EntryKind.DIRECTORY && childCount == 0;
    }

A record can carry behaviour. What it cannot carry is a field outside the header. Try:

T7.java:2: error: field declaration must be static
    private int hits;
                ^
  (consider replacing field with record component)

The parenthesised hint is javac naming the only legal place for state in a record.


Nodes.java — the two switches, and the default that is absent

    static EntryKind kindOf(Node node) {
        return switch (node) {
            case FileNode file -> EntryKind.FILE;
            case DirectoryNode dir -> EntryKind.DIRECTORY;
            case SymlinkNode link -> throw new IllegalStateException(…);
        };
    }

The absence of a default is the load-bearing part of this method. Three cases over three permitted types is complete, so the compiler accepts it with no fallback. Add SymlinkNode to permits without adding the third case and you get exactly one error, at this line:

Nodes.java:14: error: the switch expression does not cover all possible input values
        return switch (node) {
               ^
1 error

That line number is from the real corpus file, not this one. Write it with a default instead and the same edit compiles clean and answers DIRECTORY for a shortcut, which from-cpp.md measures.

The third arm throws for a case that cannot happen, and that is deliberate rather than lazy. MiniFileSystem.resolve follows shortcuts, so an unresolved one arriving here means a caller went around it. The compiler demanded a decision, and the decision is written down where the next reader finds it. Block 1 prints it firing when called directly.

    static String describe(Node node) {
        return switch (node) {
            case FileNode file when file.content().isEmpty() -> "an empty file";
            case FileNode file -> "a file of " + file.content().length() + " characters";

The guarded case must be first, and the unguarded one must exist. Those are two separate rules and each has its own error.

Reverse the pair and the unguarded case is unreachable:

T9.java:5: error: this case label is dominated by a preceding case label

Delete the unguarded case and the switch is incomplete, because a guard is a condition and the compiler will not decide whether it always holds:

TA.java:3: error: the switch expression does not cover all possible input values

What the guard guarantees: an empty file reports as an empty file rather than `a file of 0 characters`. Block 4 prints both arms firing.

            case SymlinkNode(String target) -> "a shortcut to " + target;

A record deconstruction pattern. It matches the type and binds the component in one step, so there is no link.target() call and no variable holding a node nobody uses. It only works because SymlinkNode is a record, and it reads the normalised target, because that is the only value the record ever holds.

No cast appears anywhere in either switch. That is the difference from an instanceof chain, and it is the part that shortens a skeleton sprint. Each case binds a typed variable whose scope is that arm.


MiniFileSystem.java — where the closed set is used

    Optional<Node> find(String path) {
        return Optional.ofNullable(bound(path));
    }

ofNullable, never of. Optional.of(null) throws NullPointerException, which would turn "nothing is there" into a crash inside the method whose job is to report it. Block 6 prints the empty answer as Optional.empty.

The corpus's own PathResolver.find returns a bare Node and documents "or null if nothing is there." This is that method with the sentence moved into the return type. What it guarantees: a caller cannot forget, because there is no way to use an Optional<Node> as a Node.

    private Node bound(String path) {
        …
            return null;

Private, and it is the only place in the class where a null exists. That containment is the point of the pair. One method deals in null because a TreeMap lookup does; nothing outside this class ever sees one.

    Node get(String path) {
        return find(path).orElseThrow(() -> new NoSuchPathException(path));
    }

orElseThrow with a supplier, not get(). The supplier runs only when there is nothing there, and the exception it builds names the path. Block 6 prints the two side by side:

  get() on the empty one         : java.util.NoSuchElementException: No value present
  orElseThrow, which get(path) is: NoSuchPathException: /var/missing.log

An unchecked get() is the null dereference Optional was introduced to remove, moved one call later. The message it produces has no path in it, and no caller of a file system can act on it.

        switch (parent.child(leaf)) {
            case null -> parent.addChild(leaf, new FileNode(content));

case null is required here, and its absence would be a null-pointer exception on the common path. A pattern switch throws NullPointerException for a null selector unless a null case is written. And parent.child(leaf) returns null for a name that does not exist yet, which is what creating a file means.

These four arms are curveball 01's fix to write. PATCH.md charges 4 of the file's 8 lines to it, and the reason is the fourth arm: a shortcut sitting at this name is replaced rather than followed.

    private Node follow(Node node, String path, int hops) {
        if (!(node instanceof SymlinkNode link)) {
            return node;
        }
        if (hops >= MAX_SHORTCUT_HOPS) {

The hop count is what makes resolve terminate, and it is the one real algorithm change curveball 01 asks for. TreeResolver.java is 16 of that curveball's 28 lines for this reason alone.

The invariant resolve maintains: the node it returns is never a SymlinkNode. Every caller depends on it, which is why read and kindOf can throw in their shortcut arms. Block 8 shows the guard firing on a shortcut that points at itself, and shows find deliberately not following.

if (!(node instanceof SymlinkNode link)) is the instanceof pattern rather than the switch, and it is the right shape for one test out of two. The switch earns its keep at three or more.


Main.java — two blocks that are only about syntax

        var rows = new ArrayList<Listing>();
        Optional<Node> found = fs.find("/logs/latest");

The threshold, in two lines of the same method. The first says ArrayList<Listing> on the right, so saying it on the left twice is noise. The second says fs.find, which tells a reader nothing, so the type is written out. Block 5 prints what a reader of the var version would have had: Optional with no element type.

        String expected = """
                /logs/app.log   FILE      0

The closing delimiter's line sets the margin, and its position decides the trailing newline. Here """ is on its own line, so the string ends with \n, and the Collectors.joining call that builds actual supplies one to match. Block 7 prints actual.equals(expected) : true.

What this guarantees over a concatenated string: the expected value in the source looks like the expected value in the output. So a mismatch is visible by reading, rather than by counting \n escapes. That is the only claim worth making for text blocks.


What an interviewer is measuring

Not whether you can spell sealed. Two things.

Whether you said the set was closed, and why. The design sentence is this one:

A node is a file or a directory. The requirement has no third kind, so the interface is sealed, and the one switch over it cannot compile with a case missing.

It also sets up the follow-up you want, which is what happens when a third kind arrives.

Whether the answer to that follow-up is a number. It is: 28 lines for file-system/curveballs/01-shortcuts, of which 4 belong to the sealed hierarchy and 24 are the requirement. Seven of the eight operations changed by nothing at all, because they were written in terms of PathResolver rather than a map they walked themselves.

The habit that costs you the mark is reaching for sealed on every interface. Four of the twenty corpus problems have a sealed hierarchy. Appender in corpus/logger is an ordinary interface because a curveball adds a destination, and sealing it would have broken that curveball.


Worked source

The 12 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/DirectoryNode.java41 lines

// DirectoryNode.java — corpus/file-system/reference/src/DirectoryNode.java, trimmed to the
// methods this lesson calls. The TreeMap is the corpus's choice, so ls() is the map's iteration
// order rather than a sort every call site remembers to apply.

import java.util.List;
import java.util.TreeMap;

/**
 * A directory's children, keyed by name.
 *
 * <p>Not a record either, for the same reason as {@link FileNode}: the children change. A record
 * component holding a mutable {@code TreeMap} would also hand every caller the live map through
 * the generated accessor, which is the leak A7 is about.
 */
final class DirectoryNode implements Node {

    private final TreeMap<String, Node> children = new TreeMap<>();

    Node child(String name) {
        return children.get(name);
    }

    boolean hasChild(String name) {
        return children.containsKey(name);
    }

    /** Names in sorted order, exactly as {@code ls} hands them to a caller. */
    List<String> childNames() {
        return List.copyOf(children.keySet());
    }

    int childCount() {
        return children.size();
    }

    void addChild(String name, Node node) {
        if (children.putIfAbsent(name, node) != null) {
            throw new IllegalStateException("directory already has a child named '" + name + "'");
        }
    }
}

worked/src/EntryKind.java4 lines

// EntryKind.java — copied from corpus/file-system/contract/EntryKind.java, unchanged.

/** What a resolved path turned out to be. */
public enum EntryKind { FILE, DIRECTORY }

worked/src/FileNode.java25 lines

// FileNode.java — corpus/file-system/reference/src/FileNode.java, unchanged.

/**
 * A file's content. Mutable, because {@code write} on an existing file replaces it in place.
 *
 * <p>Deliberately not a record. A record's components are final, and this one has to change after
 * construction, so the record form would force every write to build a new node and rebind it in
 * its parent directory. That is a real design difference, not a syntax preference.
 */
final class FileNode implements Node {

    private String content;

    FileNode(String content) {
        this.content = content;
    }

    String content() {
        return content;
    }

    void write(String content) {
        this.content = content;
    }
}

worked/src/IsADirectoryException.java10 lines

// IsADirectoryException.java — corpus/file-system/contract/IsADirectoryException.java, reduced to
// a RuntimeException for the same reason as NoSuchPathException.

/** The final segment is a directory where the operation needed a file. */
final class IsADirectoryException extends RuntimeException {

    IsADirectoryException(String path) {
        super(path);
    }
}

worked/src/Listing.java44 lines

// Listing.java — one row of what `ls` found: the shape corpus/file-system's ls() would return if
// it reported kinds as well as names. Modelled on corpus/trip-state-machine/contract/Transition.java,
// which is a record with a compact constructor that null-checks all four components.

import java.util.Objects;

/**
 * One entry a directory listing found, as a value.
 *
 * <p>Everything a value type needs is generated from the header line: a canonical constructor,
 * an accessor per component, {@code equals} comparing all three components, a {@code hashCode}
 * consistent with it, and a {@code toString} naming the class and every component. J6's whole
 * subject is what happens when a class that goes into a {@code HashMap} or a {@code List.contains}
 * lacks those two. A record cannot lack them.
 */
record Listing(String path, EntryKind kind, int childCount) {

    /**
     * Validation and normalisation, in the one place every constructor call has to pass through.
     *
     * <p>What it guarantees: no {@code Listing} exists with a null path, a relative path, a
     * trailing slash, or a negative child count. So no code downstream checks for any of those,
     * and {@code equals} never has to decide whether {@code "/logs"} and {@code "/logs/"} are the
     * same row.
     */
    Listing {
        Objects.requireNonNull(path, "path");
        Objects.requireNonNull(kind, "kind");
        if (!path.startsWith("/")) {
            throw new IllegalArgumentException("path must be absolute: " + path);
        }
        if (childCount < 0) {
            throw new IllegalArgumentException("childCount must not be negative: " + childCount);
        }
        if (path.length() > 1 && path.endsWith("/")) {
            path = path.substring(0, path.length() - 1);
        }
    }

    /** A record can carry behaviour. What it cannot carry is a field outside the header. */
    boolean isEmptyDirectory() {
        return kind == EntryKind.DIRECTORY && childCount == 0;
    }
}

worked/src/MiniFileSystem.java176 lines

// MiniFileSystem.java — corpus/file-system/reference/src/InMemoryFileSystem.java and TreeResolver
// folded into one class and cut to five operations, with curveball 01's shortcut following already
// applied. The corpus keeps resolution and the operations in separate files, and it is right to;
// they are together here so the lesson is one directory you can read top to bottom.

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

/** Five operations over the closed set of nodes. */
final class MiniFileSystem {

    private static final int MAX_SHORTCUT_HOPS = 40;

    private final DirectoryNode root = new DirectoryNode();

    void mkdir(String path) {
        DirectoryNode current = root;
        for (String segment : segments(path)) {
            Node existing = current.child(segment);
            if (existing == null) {
                DirectoryNode created = new DirectoryNode();
                current.addChild(segment, created);
                current = created;
            } else if (existing instanceof DirectoryNode dir) {
                current = dir;
            } else {
                throw new NotADirectoryException(path);
            }
        }
    }

    /**
     * Curveball 01's four-arm version of {@code write}, which is where 4 of that curveball's 28
     * lines went. Written as a switch here rather than the corpus's {@code if}-chain, because the
     * fourth arm is the point: a shortcut sitting at this name is replaced, not followed.
     *
     * <p>{@code case null} is not decoration. A pattern switch with no null case throws
     * {@code NullPointerException} on a null selector, and {@code parent.child(leaf)} returns null
     * for a name that is not there, which is the common path.
     */
    void write(String path, String content) {
        List<String> segments = segments(path);
        if (segments.isEmpty()) {
            throw new IsADirectoryException(path);
        }
        String leaf = segments.get(segments.size() - 1);
        DirectoryNode parent = directoryAt(segments.subList(0, segments.size() - 1), path);
        switch (parent.child(leaf)) {
            case null -> parent.addChild(leaf, new FileNode(content));
            case FileNode file -> file.write(content);
            case DirectoryNode dir -> throw new IsADirectoryException(path);
            case SymlinkNode link -> throw new IllegalStateException(
                    "replacing a shortcut is out of scope for this lesson: " + link.target());
        }
    }

    void symlink(String path, String target) {
        List<String> segments = segments(path);
        if (segments.isEmpty()) {
            throw new IllegalArgumentException("the root cannot be a shortcut");
        }
        String leaf = segments.get(segments.size() - 1);
        DirectoryNode parent = directoryAt(segments.subList(0, segments.size() - 1), path);
        parent.addChild(leaf, new SymlinkNode(target));
    }

    /**
     * What is bound at path, or empty when nothing is. Never follows a shortcut sitting at the end.
     *
     * <p>The corpus returns a bare {@code Node} here and documents "or null if nothing is there."
     * This is the same method with the documentation moved into the return type, and the whole
     * difference is that a caller cannot ignore it by accident.
     */
    Optional<Node> find(String path) {
        return Optional.ofNullable(bound(path));
    }

    /** What is bound at path, which must exist. */
    Node get(String path) {
        return find(path).orElseThrow(() -> new NoSuchPathException(path));
    }

    /** What is at path with shortcuts followed, so the answer is never a {@link SymlinkNode}. */
    Node resolve(String path) {
        return follow(get(path), path, 0);
    }

    String read(String path) {
        return switch (resolve(path)) {
            case FileNode file -> file.content();
            case DirectoryNode dir -> throw new IsADirectoryException(path);
            case SymlinkNode link -> throw new IllegalStateException(
                    "resolve() cannot return a shortcut: " + link.target());
        };
    }

    EntryKind kindOf(String path) {
        return Nodes.kindOf(resolve(path));
    }

    /** Every child of the directory at path, in name order, with shortcuts followed. */
    List<Listing> ls(String path) {
        Node node = resolve(path);
        if (!(node instanceof DirectoryNode dir)) {
            throw new NotADirectoryException(path);
        }
        List<Listing> rows = new ArrayList<>();
        for (String name : dir.childNames()) {
            String childPath = path.equals("/") ? "/" + name : path + "/" + name;
            Node child = resolve(childPath);
            int childCount = child instanceof DirectoryNode childDir ? childDir.childCount() : 0;
            rows.add(new Listing(childPath, Nodes.kindOf(child), childCount));
        }
        return List.copyOf(rows);
    }

    private Node follow(Node node, String path, int hops) {
        if (!(node instanceof SymlinkNode link)) {
            return node;
        }
        if (hops >= MAX_SHORTCUT_HOPS) {
            throw new IllegalStateException("shortcut loop reached from " + path);
        }
        return follow(get(link.target()), link.target(), hops + 1);
    }

    /** Null when nothing is bound at path. Kept private so the null never leaves this class. */
    private Node bound(String path) {
        Node current = root;
        for (String segment : segments(path)) {
            if (!(current instanceof DirectoryNode dir)) {
                return null;
            }
            Node next = dir.child(segment);
            if (next == null) {
                return null;
            }
            current = next;
        }
        return current;
    }

    private DirectoryNode directoryAt(List<String> segments, String path) {
        Node current = root;
        for (String segment : segments) {
            if (!(current instanceof DirectoryNode dir)) {
                throw new NotADirectoryException(path);
            }
            Node next = dir.child(segment);
            if (next == null) {
                throw new NoSuchPathException(path);
            }
            current = next;
        }
        if (current instanceof DirectoryNode dir) {
            return dir;
        }
        throw new NotADirectoryException(path);
    }

    private static List<String> segments(String path) {
        Objects.requireNonNull(path, "path");
        if (!path.startsWith("/")) {
            throw new IllegalArgumentException("path must be absolute: " + path);
        }
        String trimmed = path.length() > 1 && path.endsWith("/")
                ? path.substring(0, path.length() - 1)
                : path;
        if (trimmed.equals("/")) {
            return List.of();
        }
        return List.of(trimmed.substring(1).split("/", -1));
    }
}

worked/src/NoSuchPathException.java10 lines

// NoSuchPathException.java — corpus/file-system/contract/NoSuchPathException.java, reduced to a
// RuntimeException so this lesson ships one exception file rather than the contract's five.

/** Some segment of the path, including the last one, names nothing. */
final class NoSuchPathException extends RuntimeException {

    NoSuchPathException(String path) {
        super(path);
    }
}

worked/src/Node.java13 lines

// Node.java — corpus/file-system/reference/src/Node.java, with curveball 01's third variant
// already applied (curveballs/01-shortcuts/reference-patch/Node.java).

/**
 * A thing that can sit at a path. The set is closed, and this line is the only place that says so.
 *
 * <p>Two consequences, and both of them are the reason this lesson exists. No class outside the
 * {@code permits} list can implement this, so nobody can add a fourth kind from another file.
 * And every {@code switch} over a {@code Node} is checked against exactly this list, so adding
 * {@code SymlinkNode} here was a compile error at every switch that had not heard of it.
 */
sealed interface Node permits FileNode, DirectoryNode, SymlinkNode {
}

worked/src/Nodes.java54 lines

// Nodes.java — corpus/file-system/reference/src/Nodes.java, with curveball 01's forced third case
// applied, plus one extra switch this lesson adds to show guards and deconstruction.

/**
 * The only place in this design that asks a {@link Node} which kind it is.
 *
 * <p>Keeping both switches in one small class is the decision worth defending. A new variant
 * breaks compilation here, at two named lines, instead of being a runtime surprise spread across
 * every call site that happened to ask.
 */
final class Nodes {

    /**
     * The corpus switch, unchanged apart from the third case.
     *
     * <p>No {@code default}, and its absence is the whole design. With three cases listed and a
     * sealed type of exactly three permitted implementations, the compiler knows the cases are all
     * of them. Add a {@code default} here and it stops knowing, because a {@code default} covers
     * whatever arrives next — including a variant nobody thought about.
     */
    static EntryKind kindOf(Node node) {
        return switch (node) {
            case FileNode file -> EntryKind.FILE;
            case DirectoryNode dir -> EntryKind.DIRECTORY;
            case SymlinkNode link -> throw new IllegalStateException(
                    "resolve() must never hand back an unresolved shortcut: " + link.target());
        };
    }

    /**
     * The same closed set, asked a question with more shape to it.
     *
     * <p>Three things happen here that an {@code instanceof} chain would spell out by hand. Each
     * case binds a typed variable, so no cast appears. Two cases carry a {@code when} guard, which
     * splits one variant into two answers without a nested {@code if}. And the shortcut case
     * destructures the record, binding {@code target} straight out of it.
     *
     * <p>Order matters here in one direction only: a guarded case must come before the unguarded
     * case for the same type, because the unguarded one covers everything the guarded one would
     * have. Reverse them and {@code javac} says
     * {@code error: this case label is dominated by a preceding case label}.
     */
    static String describe(Node node) {
        return switch (node) {
            case FileNode file when file.content().isEmpty() -> "an empty file";
            case FileNode file -> "a file of " + file.content().length() + " characters";
            case DirectoryNode dir when dir.childCount() == 0 -> "an empty directory";
            case DirectoryNode dir -> "a directory of " + dir.childCount() + " entries";
            case SymlinkNode(String target) -> "a shortcut to " + target;
        };
    }

    private Nodes() {}
}

worked/src/NotADirectoryException.java10 lines

// NotADirectoryException.java — corpus/file-system/contract/NotADirectoryException.java, reduced
// to a RuntimeException for the same reason as the other two.

/** A segment the operation needed to be a directory is a file instead. */
final class NotADirectoryException extends RuntimeException {

    NotADirectoryException(String path) {
        super(path);
    }
}

worked/src/SymlinkNode.java32 lines

// SymlinkNode.java — the third variant, from
// corpus/file-system/curveballs/01-shortcuts/reference-patch/SymlinkNode.java.
//
// The corpus writes it as a final class with one final field, one constructor and one accessor:
// eight non-blank lines. A record is the same object in one declaration, and it arrives with
// equals, hashCode and toString that the class version never had.

import java.util.Objects;

/** A shortcut: not a file or a directory itself, just a pointer at another path. */
record SymlinkNode(String target) implements Node {

    /**
     * The compact constructor. It runs before the fields are assigned, and the parameters are
     * ordinary local variables until then, so this is the one place a component's value can still
     * be changed.
     *
     * <p>Two jobs, and both of them are why this record is not a struct. It refuses a target that
     * could never resolve, so no {@code SymlinkNode} anywhere in the program needs checking again.
     * And it strips a trailing slash, so {@code "/logs/"} and {@code "/logs"} produce records that
     * are {@code equals} — normalisation happening once, here, rather than at every comparison.
     */
    SymlinkNode {
        Objects.requireNonNull(target, "target");
        if (!target.startsWith("/")) {
            throw new IllegalArgumentException("a shortcut target must be absolute: " + target);
        }
        if (target.length() > 1 && target.endsWith("/")) {
            target = target.substring(0, target.length() - 1);
        }
    }
}

worked/src/Main.java161 lines

// Main.java — eight blocks, in the order the lesson argues them. Blocks 1 to 4 are the spine:
// a closed set of variants, records as its members, and one switch the compiler checks.

import java.lang.reflect.RecordComponent;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

public final class Main {

    /**
     * Static, so its name survives into the class file and the null-pointer message can quote it.
     * A local would read as {@code <local4>} unless javac ran with {@code -g}.
     */
    private static Optional<Node> cached;

    public static void main(String[] args) {
        MiniFileSystem fs = new MiniFileSystem();
        fs.mkdir("/logs");
        fs.mkdir("/var");
        fs.mkdir("/tmp");
        fs.write("/logs/app.log", "connected\nretrying\ngateway timed out");
        fs.write("/logs/empty.log", "");
        fs.symlink("/logs/latest", "/logs/app.log");
        fs.symlink("/tmp/loop", "/tmp/loop");

        closedSet(fs);
        whatTheHeaderGenerated();
        compactConstructor();
        oneSwitch(fs);
        varWhereTheTypeIsAlreadyThere(fs);
        optionalAsAReturnType(fs);
        textBlockAsExpectedOutput(fs);
        shortcutLoop(fs);
    }

    private static void closedSet(MiniFileSystem fs) {
        System.out.println("--- 1. the closed set, and what the compiler knows about it");
        System.out.println("  Node.class.isSealed()          : " + Node.class.isSealed());
        List<String> permitted = new ArrayList<>();
        for (Class<?> c : Node.class.getPermittedSubclasses()) {
            permitted.add(c.getSimpleName());
        }
        System.out.println("  permitted implementations      : " + String.join(", ", permitted));
        System.out.println("  kindOf(/logs)                  : " + fs.kindOf("/logs"));
        System.out.println("  kindOf(/logs/app.log)          : " + fs.kindOf("/logs/app.log"));
        System.out.println("  kindOf(/logs/latest)           : " + fs.kindOf("/logs/latest")
                + "  (the shortcut was followed)");
        System.out.println("  kindOf on a raw shortcut       : "
                + thrown(() -> Nodes.kindOf(new SymlinkNode("/logs/app.log"))));
    }

    private static void whatTheHeaderGenerated() {
        System.out.println("--- 2. what the record header generated");
        Listing one = new Listing("/logs", EntryKind.DIRECTORY, 3);
        Listing two = new Listing("/logs", EntryKind.DIRECTORY, 3);
        System.out.println("  Listing.class.isRecord()       : " + Listing.class.isRecord());
        List<String> components = new ArrayList<>();
        for (RecordComponent component : Listing.class.getRecordComponents()) {
            components.add(component.getType().getSimpleName() + " " + component.getName());
        }
        System.out.println("  components                     : " + String.join(", ", components));
        System.out.println("  toString                       : " + one);
        System.out.println("  accessor one.childCount()      : " + one.childCount());
        System.out.println("  one == two                     : " + (one == two));
        System.out.println("  one.equals(two)                : " + one.equals(two));
        System.out.println("  hashCodes agree                : " + (one.hashCode() == two.hashCode()));
        System.out.println("  List.of(one).contains(two)     : " + List.of(one).contains(two));
        System.out.println("  FileNode.class.isRecord()      : " + FileNode.class.isRecord()
                + "  (its content changes after construction)");
    }

    private static void compactConstructor() {
        System.out.println("--- 3. the compact constructor, doing the two jobs it exists for");
        Listing trailing = new Listing("/logs/", EntryKind.DIRECTORY, 3);
        System.out.println("  \"/logs/\" normalised to         : " + trailing.path());
        System.out.println("  equal to the version without   : "
                + trailing.equals(new Listing("/logs", EntryKind.DIRECTORY, 3)));
        System.out.println("  a negative child count         : "
                + thrown(() -> new Listing("/logs", EntryKind.DIRECTORY, -1)));
        System.out.println("  a relative path                : "
                + thrown(() -> new Listing("logs", EntryKind.DIRECTORY, 0)));
        System.out.println("  new SymlinkNode(\"/logs/\")      : " + new SymlinkNode("/logs/"));
        System.out.println("  equal to the version without   : "
                + new SymlinkNode("/logs/").equals(new SymlinkNode("/logs")));
        System.out.println("  a null target                  : "
                + thrown(() -> new SymlinkNode(null)));
    }

    private static void oneSwitch(MiniFileSystem fs) {
        System.out.println("--- 4. one switch, five answers, no casts");
        for (String path : List.of("/logs", "/var", "/logs/app.log", "/logs/empty.log")) {
            System.out.printf("  %-30s : %s%n", "describe(" + path + ")",
                    Nodes.describe(fs.get(path)));
        }
        System.out.printf("  %-30s : %s%n", "describe(/logs/latest)",
                Nodes.describe(fs.get("/logs/latest")));
        System.out.println("  describe(null)                 : " + thrown(() -> Nodes.describe(null)));
    }

    private static void varWhereTheTypeIsAlreadyThere(MiniFileSystem fs) {
        System.out.println("--- 5. var, where the right-hand side already names the type");
        var rows = new ArrayList<Listing>();
        var row = new Listing("/logs", EntryKind.DIRECTORY, 3);
        rows.add(row);
        System.out.println("  var rows = new ArrayList<Listing>() : " + rows.getClass().getSimpleName()
                + ", holding " + rows.size());
        System.out.println("  var row  = new Listing(..)           : " + row.getClass().getSimpleName());
        Optional<Node> found = fs.find("/logs/latest");
        System.out.println("  Optional<Node> found = fs.find(..)   : " + found.getClass().getSimpleName()
                + " is all 'var found' would have told a reader");
    }

    private static void optionalAsAReturnType(MiniFileSystem fs) {
        System.out.println("--- 6. Optional, as a return type and nothing else");
        System.out.println("  find(/logs/latest)             : " + fs.find("/logs/latest"));
        System.out.println("  find(/var/missing.log)         : " + fs.find("/var/missing.log"));
        System.out.println("  map then orElse                : "
                + fs.find("/var/missing.log").map(Nodes::describe).orElse("nothing is there"));
        System.out.println("  get() on the empty one         : "
                + thrown(() -> fs.find("/var/missing.log").get()));
        System.out.println("  orElseThrow, which get(path) is: "
                + thrown(() -> fs.get("/var/missing.log")));
        System.out.println("  an Optional reference is itself nullable:");
        System.out.println("    " + thrown(() -> cached.isPresent()));
    }

    private static void textBlockAsExpectedOutput(MiniFileSystem fs) {
        System.out.println("--- 7. a text block holding the expected listing");
        String expected = """
                /logs/app.log   FILE      0
                /logs/empty.log FILE      0
                /logs/latest    FILE      0
                """;
        String actual = fs.ls("/logs").stream()
                .map(r -> String.format("%-16s%-10s%d", r.path(), r.kind(), r.childCount()))
                .collect(Collectors.joining("\n", "", "\n"));
        System.out.println("  actual.equals(expected)        : " + actual.equals(expected));
        System.out.println("  the expected block, as stored, three lines and one trailing newline:");
        System.out.print(expected.replaceAll("(?m)^", "    "));
    }

    private static void shortcutLoop(MiniFileSystem fs) {
        System.out.println("--- 8. a shortcut that points at itself");
        System.out.println("  read(/tmp/loop)                : " + thrown(() -> fs.read("/tmp/loop")));
        System.out.println("  find(/tmp/loop) does not follow: " + fs.find("/tmp/loop"));
    }

    /** The exception a call produced, as one line, so a block stays readable. */
    private static String thrown(Runnable call) {
        try {
            call.run();
            return "no exception";
        } catch (RuntimeException e) {
            return e.toString();
        }
    }

    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.

← J10 · Enums as real classes — constants that carry state and behaviour J12 · Concurrency primitives, mapped — synchronized, volatile, Atomic*, and happens-before →

← all lessons