In a language with sealed type hierarchies and exhaustive pattern matching, is the classic Visitor pattern still worth using?
answer
- Sealed + exhaustive match = same safety, no boilerplate
- Visitor survives for published extension points
- Sealing needs one module / closed set
- Record/deconstruction patterns beat visitors on nested rules
- Neither solves the expression problem; type classes do
basics
~20 sOften not. Sealed types let the compiler check that a switch covers every case, giving the same safety without accept/visit boilerplate. Visitor still earns its place for open hierarchies, published extension interfaces, and shared traversal or state across many cases.
solid answer
~50 sSealed hierarchies (Java sealed interfaces, Kotlin sealed classes, Rust/Swift enums, Scala ADTs, TypeScript discriminated unions) plus exhaustive `switch`/`match` deliver Visitor's central guarantee — every element type is handled, checked at compile time — with far less machinery: no `Visitor` interface, no per-element `accept`, and the operation reads top-to-bottom in one place. Adding an operation is a new function; adding a case still breaks every match, so the extensibility trade is unchanged, only the syntax is cheaper. Visitor still wins when: the hierarchy cannot be sealed (plugins, cross-module or cross-library element types); you want to *publish* an extension point so third parties supply operations against a stable contract; you need shared per-node infrastructure (default traversal, enter/exit hooks, accumulated context) that a base visitor supplies and a bare match cannot; or the ecosystem's generated code already hands you visitors (ANTLR, protobuf, annotation processors). My default in a modern sealed-type language is exhaustive matching, and I reach for Visitor when I need a published, extensible operation contract.
code
java · 14 linessealed interface Expr permits Num, Add, Mul {}
record Num(int v) implements Expr {}
record Add(Expr l, Expr r) implements Expr {}
record Mul(Expr l, Expr r) implements Expr {}
// No accept(), no Visitor interface; compiler enforces exhaustiveness.
static int eval(Expr e) {
return switch (e) {
case Num(int v) -> v;
case Add(Expr l, Expr r) -> eval(l) + eval(r);
case Mul(Num(int z), Expr r) when z == 0 -> 0; // nested pattern: no Visitor equivalent
case Mul(Expr l, Expr r) -> eval(l) * eval(r);
};
}go deeper
Say that some languages can check a switch covers every possible type, which achieves what Visitor achieves with less code.
Show the sealed-interface switch, note the compiler-enforced exhaustiveness, and name the boilerplate saved.
Explain when Visitor still wins (open hierarchies, published extension points, shared traversal/base visitor, generated code) and that the extensibility trade is unchanged.
Position Visitor historically as compensation for missing language features, distinguish syntactic from architectural need, cover nested/record patterns, sealing's module constraints, and the real expression-problem solutions (type classes, protocols, multimethods) with their coherence costs.
### The two mechanisms, defined **Visitor** solves "handle each concrete element type, exhaustively, with the operation living outside the elements" via double dispatch: `element.accept(v)` then `v.visit(this)`. Exhaustiveness is enforced because the `Visitor` interface declares one method per element type and interfaces must be fully implemented. **Sealed hierarchy + exhaustive matching** solves the same problem via the type system. A **sealed** type declares a closed, compiler-known set of permitted subtypes (Java `sealed interface Shape permits Circle, Square`, Kotlin `sealed class`, Scala `sealed trait`, Rust/Swift `enum` variants, TypeScript unions with a discriminant field). Because the compiler knows the complete set, it can prove a `switch`/`match` covers every case and reject (or warn on) one that doesn't. ``` // Same operation, no accept/visit anywhere double area(Shape s) = switch (s) { case Circle c -> Math.PI * c.r() * c.r(); case Square q -> q.side() * q.side(); // add a Triangle to the sealed set -> this switch stops compiling }; ``` Adding `Triangle` to `permits` makes every non-exhaustive switch in the compilation unit fail — the same compile-error checklist Visitor gives you, with no boilerplate. ### What you actually give up by dropping Visitor 1. **Open extension of operations by third parties.** A published `Visitor` interface is an *extension point*: outsiders implement it and plug into your walk. A bare `switch` inside your function is closed — outsiders cannot add behavior; they can only write their own switch (which they can, if the types are public, but they get no framework services from you). 2. **Shared traversal and defaults.** A `BaseVisitor` gives every pass a free full child walk, enter/exit hooks, error accumulation, and context. Reproducing that with plain matching means writing a higher-order walk function that takes per-case lambdas — doable and often nicer, but it is the same idea rebuilt. 3. **Grouping by operation across module boundaries.** With sealed types the *type set* must live in one module (sealing is a same-module/same-package constraint in Java; Kotlin allows same-module). If your elements are genuinely spread across modules or supplied by plugins, you cannot seal them, and Visitor's interface-based enumeration is the available tool. 4. **Ecosystem inertia.** ANTLR, protobuf, JavaPoet-style processors, javax.lang.model, and many compiler frameworks *hand you* visitors. Fighting that to use matching is usually not worth it. ### What you gain by dropping Visitor - **Readability.** One function, cases in order, local reasoning. No jumping between `accept` and `visit` and a base class. - **No N×M boilerplate.** No per-element `accept`, no interface to keep in sync, no `super.visitX()` discipline. - **Values, not state.** Matching naturally returns values; classic void visitors accumulate mutable state and are non-reentrant. - **Local operations stay local.** A one-off operation is a private function, not a public class. - **Deconstruction/record patterns** (Java 21+, Scala, Rust, Swift) let you match on *nested shape* — `case Addition(NumberLiteral(0), var r) -> r` — which Visitor cannot express without manual nested checks. This is genuinely more expressive than double dispatch for optimizer-style rules. ### Where the expression problem stands after all this Unchanged. Sealed + matching is the *functional* grouping (by operation), so it has the same profile as Visitor: cheap new operations, expensive new cases. It is Visitor with better ergonomics, not a solution to the underlying trade. Actual solutions to the expression problem require different machinery — type classes / traits with coherent instance resolution (Haskell, Rust), Scala implicits, Clojure protocols, open multimethods (CLOS, Julia) — all of which let both axes extend without editing existing code, at the price of a more complex mental model and, in some, coherence/orphan-instance rules. ### A pragmatic decision procedure 1. Can the element set be **sealed** (you own it, it lives in one module, it's closed)? If yes → **exhaustive matching** by default. 2. Do **third parties need to add operations** through a contract you publish? → **Visitor** (or an interface-shaped extension point). 3. Do many operations share **nontrivial traversal, context, or defaults**? → **base visitor**, or a shared higher-order walk function with per-case handlers. 4. Is the tooling already **generating visitors**? → use them. 5. Is it a **one-off** operation? → a plain function with a match; introduce nothing. ### Interview framing The strong answer says: Visitor was invented to compensate for languages lacking multiple dispatch and exhaustive sum-type matching. As languages acquired sealed types and pattern matching (Java 17–21, C# 9+, Kotlin, Scala, Rust, Swift, TypeScript unions), the *syntactic* need for Visitor shrank, but the *architectural* need — a published, stable extension point for operations over an element set — did not.
- Does sealed + exhaustive matching solve the expression problem?No. It is the same 'group by operation' side of the trade as Visitor: adding a case still breaks every match. It only removes the boilerplate. Genuine solutions need type classes/traits with coherent resolution, Scala implicits, Clojure protocols, or open multimethods.
- When can't you seal a hierarchy?When the element types are contributed by other modules or third-party plugins, or when the language requires permitted subtypes to be in the same module/package (Java) and your types are spread out. Then the visitor interface is the practical enumeration mechanism.
- What can pattern matching express that Visitor cannot express directly?Nested/deconstruction patterns and guards — matching on the shape of a subtree in one case, such as 'a multiplication whose left operand is the literal zero'. Visitor dispatches on one node's type at a time, so multi-level rules become manual nested checks.
Visitor is a hand-built ledger to guarantee no account is missed; sealed types plus exhaustive matching is an accounting system that refuses to close the books until every account is reconciled. You keep the hand-built ledger only where outside parties must add their own entries.
saying these in an interview costs you the question
- Claiming pattern matching solves the expression problem — it has the same extensibility profile as Visitor
- Asserting Visitor is obsolete, ignoring published extension points and non-sealable hierarchies
- Using a non-exhaustive if/instanceof chain with a default branch and calling it equivalent — the default silently swallows new types
- Sealing types purely to avoid boilerplate when third parties must extend the hierarchy
- Assuming sealed subtypes can live anywhere; most languages constrain them to the same module or package