skip to content

How do Java sealed types and pattern-matching switch provide a modern alternative to the Visitor pattern, and when would you still prefer classic Visitor?

level: principalimportance: should knowfreq 45%

answer

  1. sealed permits = closed, known subtype set
  2. switch type patterns branch on runtime type directly
  3. Exhaustive switch over sealed = no default, compiler-checked
  4. Add element -> every switch fails to compile (guided)
  5. Keep Visitor for open hierarchies / old runtimes / published APIs

basics

~20 s

A sealed interface lists all its permitted subtypes, so a pattern-matching switch over them can be checked by the compiler for completeness. You write each operation as a switch instead of a visitor, with no accept methods — cleaner for adding operations, while the compiler still flags you when a new element type is added.

solid answer

~50 s

Sealing a type (sealed interface Node permits NumberNode, AddNode) tells the compiler the complete, closed set of subtypes. A switch with type patterns (case AddNode a -> ...) over a sealed type becomes *exhaustive*: the compiler verifies every permitted subtype is handled and rejects the switch (no default needed) if one is missing. This replaces the Visitor's accept/visit boilerplate: each operation is just a method containing a switch, added without touching the element classes — Visitor's main win, with far less ceremony and no double-dispatch indirection. Crucially it also softens Visitor's weakness: when you add a new permitted subtype, every exhaustive switch over that type fails to compile, pointing you to exactly the operations that need updating. You'd still prefer classic Visitor when the element types aren't yours to seal (a third-party/open hierarchy), when you target pre-Java-17/21 runtimes, when you need stateful visitor objects or traversal hooks (enter/exit), or when a stable published Visitor API is part of your contract.

code

java · 15 lines
java
sealed interface Node permits NumberNode, AddNode, MulNode {}
record NumberNode(int value) implements Node {}
record AddNode(Node left, Node right) implements Node {}
record MulNode(Node left, Node right) implements Node {}

// Each operation is just a method with an exhaustive switch — no accept/visit.
static int eval(Node n) {
    return switch (n) {
        case NumberNode num            -> num.value();
        case AddNode(Node l, Node r)   -> eval(l) + eval(r); // record deconstruction
        case MulNode(Node l, Node r)   -> eval(l) * eval(r);
        // no default: adding a 4th permitted Node makes this fail to compile,
        // pointing you at every switch that must handle the new case.
    };
}

go deeper

for a junior

Knows sealed types limit which classes can implement an interface and that switch can match on type.

for a middle

Can write an exhaustive pattern-matching switch over a sealed hierarchy and explain it replaces accept/visit boilerplate.

for a senior

Explains exhaustiveness checking, how adding an element type triggers compile errors, and the no-default rule.

for a principal

Weighs sealed+switch vs Visitor across runtime targets, open vs closed hierarchies, stateful traversal, published APIs, and frames it against the expression problem; mentions record deconstruction/guards.

## The two ingredients **Sealed types** (Java 17): a `sealed interface` or `sealed class` declares an explicit, *closed* list of allowed subtypes via `permits`: ```java sealed interface Node permits NumberNode, AddNode, MulNode {} record NumberNode(int value) implements Node {} record AddNode(Node left, Node right) implements Node {} record MulNode(Node left, Node right) implements Node {} ``` "Sealed" means *no other class can implement Node* — the compiler knows the full universe of `Node`s. (Permitted subtypes must be `final`, `sealed`, or `non-sealed`.) **Pattern matching for switch** (standard in Java 21): a `switch` can match on the *runtime type* of a value with **type patterns**, binding a typed variable: ```java static int eval(Node n) { return switch (n) { case NumberNode num -> num.value(); case AddNode a -> eval(a.left()) + eval(a.right()); case MulNode m -> eval(m.left()) * eval(m.right()); }; } ``` ## Why this replaces Visitor The whole reason Visitor exists is to branch behaviour on the concrete element type *and* let you add operations without editing elements. A type-pattern `switch` branches on the runtime type **directly** — that's the multiple-dispatch-by-hand that `accept`/`visit` was simulating, but expressed in one place with no callback boilerplate. - **Add a new operation:** write a new method with its own `switch`. Element records are untouched — same win as Visitor, far less code (no `Visitor` interface, no `accept` methods, no per-visitor implementing). - **Exhaustiveness checking:** because `Node` is sealed, the compiler knows all cases. If your `switch` covers every permitted subtype, **no `default` is required**, and if you *omit* a case the code **does not compile**. This is the same compile-time safety Visitor gives (an abstract `visit` per type forces implementations) — but without the interface. ## How it tames Visitor's classic weakness Visitor's pain was: add an element type → silently must edit every visitor. With sealed + switch, adding `SubtractNode` to the `permits` list makes **every exhaustive switch over `Node` stop compiling**, with the compiler pointing at each switch that now misses a case. You still have to edit each operation — that cost is inherent to the expression problem — but it becomes **compiler-guided and impossible to forget**, rather than a silent runtime hazard. (To get a fail-fast default during transitions, you can throw in a `default`, but then you lose exhaustiveness; prefer no `default`.) ## When to still prefer classic Visitor 1. **You can't seal the hierarchy.** If element types are defined by third parties or are meant to be open for arbitrary extension, you can't enumerate them with `permits`. A published `Visitor` interface lets outsiders plug in. 2. **Old runtimes.** Targeting Java < 17 (no sealed) or < 21 (no standard switch patterns) rules it out; Visitor works on any version. 3. **Stateful / structured traversal.** A visitor *object* can carry accumulators across calls and offer paired hooks (`preVisitDirectory`/`postVisitDirectory`, enter/exit). Modelling that with bare switches is clumsier. 4. **A stable Visitor API is your contract.** Frameworks (e.g. `javax.lang.model`) publish visitor interfaces as their extension point; switching to internal pattern matching would break clients. 5. **Double dispatch on two open hierarchies.** If behaviour truly depends on two independently-extensible types, neither switch nor single Visitor is ideal; you'd combine techniques. ## Nuances worth mentioning - Pattern switch supports **guards** (`case AddNode a when a.left() instanceof NumberNode`) and **record deconstruction patterns** (`case AddNode(Node l, Node r)`), giving expressive, declarative traversals Visitor can't match concisely. - `null` handling is explicit (`case null ->`), avoiding surprise NPEs. - Performance is comparable; both compile to type checks. The choice is about extensibility, safety, and clarity — not speed. ## Bottom line For a hierarchy *you own and can seal*, sealed types + pattern-matching switch are the modern, less-boilerplate, compiler-checked replacement for Visitor, and they convert Visitor's silent "add-element" hazard into a compile error. Keep classic Visitor for open/third-party hierarchies, pre-21 runtimes, stateful or hook-based traversals, and published visitor APIs.

  • If you add a new permitted subtype to a sealed interface, what happens to existing exhaustive switches?
    They stop compiling — the compiler flags each switch that no longer covers all permitted subtypes, pointing you to every operation that must handle the new type. That turns Visitor's silent hazard into a guided compile error.
  • Why is a default clause discouraged in an exhaustive switch over a sealed type?
    A default makes the switch trivially exhaustive, so the compiler no longer warns when you add a new subtype — you lose the very compile-time safety that motivated sealing. Omit default and handle each case explicitly.

saying these in an interview costs you the question

  • Claiming pattern-matching switch needs a default over a sealed type (it doesn't, and adding one defeats exhaustiveness)
  • Saying sealed+switch removes the expression-problem cost entirely (it only makes the element-add cost compiler-guided)
  • Recommending sealed+switch for a third-party/open element hierarchy you can't seal
  • Forgetting it requires modern Java (sealed 17, switch patterns 21)

context