Free Interactive Course · Design Patterns

Visitor Design Pattern

Add new operations to a fixed set of node types without editing a single one of them — and understand what you trade away for it.

Behavioural Patternsharda.k.a. Double Dispatch
ShareXLinkedIn
In one sentence

Represent an operation to be performed on the elements of an object structure, letting you define a new operation without changing the classes of the elements on which it operates.

01

The problem Visitor solves

The problem

A document model with five node types: Heading, Paragraph, Table, Image, CodeBlock. Sensible classes, stable for two years — nobody adds node types any more.

What does keep arriving is operations. Render to HTML. Export to plain text. Count words. Estimate reading time. Extract every image URL for the CDN pre-warm. Validate accessibility. Produce a table of contents. Each one is a new method on all five classes.

So Paragraph now knows about HTML escaping, plain-text wrapping, word counting, reading speed, CDN URLs, WCAG rules and heading levels. It changes every time an unrelated feature ships, its imports span half the codebase, and a bug in the accessibility checker means editing the same file as the HTML renderer. Five classes, seven reasons each to change — and the seven operations are scattered across five files instead of living in one each.

02

How the Visitor pattern works

Turn each operation into its own class, and give the nodes one method that lets an operation in:

  1. Define a visitor interface with one method per node type: visitHeading(), visitParagraph(), visitTable()…
  2. Each operation becomes one class implementing that interface. HtmlRenderer holds all the HTML knowledge; WordCounter holds all the counting.
  3. Every node gets a single method — accept(visitor) — whose only job is to call the right visit… method back on the visitor.
  4. That two-step call is double dispatch: the first call picks the node type, the second picks the operation. A new operation is one new file and zero edits to the nodes.
You are choosing a side of the expression problem. Ordinary object orientation makes adding a type cheap (one new class) and adding an operation expensive (edit every class). Visitor flips it exactly: a new operation is one file, and a new node type means editing every visitor you have. That's not a flaw — it's the trade, and it's the only question worth asking before you reach for this pattern. Stable set of types, growing set of operations → Visitor. Growing set of types → do not.
NODES · stableOPERATIONS · growingHeading · accept()Paragraph · accept()Table · accept()Image · accept()«interface» VisitorvisitHeading() · visitTable() …HtmlRendererWordCounterA11yChecker+ the next one: 1 fileaccept(v)node.accept(v) → v.visitParagraph(node) ← two calls, one per axisadding a green box costs nothing on the left — adding a left box costs every box on the right
Participants. Each Element implements accept(Visitor). The Visitor interface declares one method per concrete element, and each Concrete Visitor is one whole operation. The double bounce — accept then visitX — exists because most languages dispatch on the receiver's type only; it's how you get dispatch on two types at once.
03

See it: swap the operation, keep the tree

The document below never changes. Pick an operation and walk it — each visitor produces something completely different from the same five nodes, and none of the node classes was edited to make it possible.

▶ Try it — three visitors, one document

An interactive visitor walk: the same document of Heading, Paragraph, Table, Image and CodeBlock nodes is visited by an HTML renderer, a word counter and an accessibility checker, each producing a different result without any node class being modified.

Notice what each visitor does with CodeBlock: the renderer wraps it, the word counter deliberately skips it, the checker inspects its language attribute. Three genuinely different policies about the same node — and each lives beside the rest of its own operation rather than inside CodeBlock. That's the readability win people forget to mention when they describe Visitor as merely "extensible".
04

Visitor pattern code examples

The classic double dispatch — and, in four of these languages, the thing you should probably write instead.

public interface Visitor<R> {
    R visitHeading(Heading h);
    R visitParagraph(Paragraph p);
    R visitTable(Table t);
    R visitImage(Image i);
    R visitCodeBlock(CodeBlock c);
}

public interface Node {
    <R> R accept(Visitor<R> visitor);
}

public record Heading(String text, int level) implements Node {
    // The entire body of this method exists to recover the static type — this
    // is the second half of the double dispatch, and the whole trick.
    @Override public <R> R accept(Visitor<R> visitor) { return visitor.visitHeading(this); }
}

/** One operation, one class, all of its knowledge in one place. */
public final class WordCounter implements Visitor<Integer> {
    public Integer visitHeading(Heading h)     { return words(h.text()); }
    public Integer visitParagraph(Paragraph p) { return words(p.text()); }
    public Integer visitTable(Table t)         { return t.cells().stream().mapToInt(this::words).sum(); }
    public Integer visitImage(Image i)         { return words(i.alt()); }
    public Integer visitCodeBlock(CodeBlock c) { return 0; }   // code isn't prose
}

int total = document.nodes().stream().mapToInt(n -> n.accept(new WordCounter())).sum();

// Java 21 sealed interfaces + pattern matching remove the accept() ceremony
// entirely, and keep the exhaustiveness check:
//
//     sealed interface Node permits Heading, Paragraph, Table, Image, CodeBlock {}
//
//     int words(Node n) = switch (n) {
//         case Heading h   -> words(h.text());
//         case CodeBlock c -> 0;
//         …
//     };   // adding a 6th permitted type makes this switch fail to compile
Read across the tabs — this is the pattern where the honest answer differs most by language. The accept()/visit() dance exists to work around single dispatch, and four of these languages no longer need it: TypeScript's discriminated union with a never guard, C#'s switch expressions on records, C++'s std::variant with std::visit, and Java 21's sealed interfaces all give you the same dispatch plus compiler-checked exhaustiveness. Python and JavaScript can't check exhaustiveness at all, which is why both tabs throw on an unknown node rather than returning a plausible default. Write the classic interface version when the set of node types is open — when code you don't control adds types — and use your language's matching everywhere else.
05

How to implement Visitor

  1. Check the trade first: are the node types stable and the operations growing? If node types keep arriving, stop — this pattern will cost you on every one.
  2. If your language has exhaustive pattern matching over a closed set, use it instead of accept(). Same benefit, far less machinery, and a compile-time completeness check.
  3. Otherwise define the visitor interface with one method per concrete node type, and give every node an accept() that does nothing but call back.
  4. Make the visitor generic in its return type so visitors can compute values, not just mutate a field.
  5. Decide who drives the traversal — the nodes (each accept visits its children) or the visitor. Visitor-driven gives you pruning and ordering control; node-driven is less code.
  6. Fail loudly on an unhandled node type. In a dynamic language that throw is the only thing standing between you and an operation that silently ignores new nodes.
  7. Keep per-visit state inside the visitor instance, and say clearly whether a visitor is reusable across walks or single-use.
06

When to use Visitor — and when not to

Use it when an object structure has many distinct node classes and you keep needing new operations over all of them; when those operations are unrelated to each other and would pollute the node classes; and — critically — when the set of node types is stable. Compilers, linters, ASTs, document models and query planners are its home ground.

Where it goes wrong

Node types that keep growing. The expensive half of the trade. Every new node type means editing every visitor you own, and if visitors live in other repositories you've just published a breaking change. This is the reason not to use Visitor, and it's usually knowable in advance.

Breaking encapsulation. Visitors need the node's data, so nodes end up exposing everything through getters — which is precisely the encapsulation the classes were meant to provide.

Ceremony for a small hierarchy. Three node types and two operations do not need an interface, five accept() methods and double dispatch. A switch is clearer and shorter.

Silent gaps in dynamic languages. A visitor object missing a handler for one node type just… skips it. The word count is quietly wrong, and no test fails unless you wrote one for that node. Throw on unknown types.

Traversal logic duplicated in every visitor. If each visitor re-implements the tree walk, a base visitor or a separate traversal function should own it — otherwise a nesting bug has to be fixed N times.

You want to…UseBecause
Add operations over a stable set of node typesVisitorNew operation, one file, zero edits to the nodes.
Add node types over a stable set of operationsplain polymorphismOrdinary OO makes exactly the opposite trade — that's the expression problem.
Walk a structure without type-aware behaviourIteratorIterator yields elements; Visitor brings behaviour per element type.
Define the tree the visitor will walkCompositeThe classic pairing: Composite builds it, Visitor operates on it.
Evaluate a small language's expression treeInterpreterInterpreter puts evaluation in the nodes; Visitor keeps it outside them.
07

Quick check

🧠 Quick check
You have five node types and seven visitors. Product asks for a sixth node type, Callout. What does that cost?

In the wild

JavaScriptBabel and ESLint plugins — you export an object of methods keyed by AST node type, which is the most widely deployed Visitor implementation anywhere.
Gogo/ast.Visitor and ast.Walk; returning nil from Visit prunes that subtree, giving you traversal control for free.
Javajavax.lang.model.element.ElementVisitor in annotation processing, and FileVisitor driving Files.walkFileTree.
C#ExpressionVisitor, which is how every LINQ provider rewrites an expression tree into SQL; Roslyn's CSharpSyntaxVisitor does the same for source.
Pythonast.NodeVisitor with its visit_<NodeType> convention — the standard library's own visitor, used by linters and formatters.
C++Clang's RecursiveASTVisitor, and std::visit over std::variant for the compile-time-checked version.

Frequently asked questions

What is double dispatch and why does Visitor need it?
Most languages dispatch on one type — the receiver's — so visitor.visit(node) picks an overload from the node's static type, which is usually the base Node. Visitor gets around that with two calls: node.accept(v) dispatches on the node's real type, and inside that method v.visitParagraph(this) dispatches on the visitor's. Two dispatches, one per axis — hence the name, and hence the otherwise pointless-looking one-line accept() methods.
What is the expression problem?
The observation that it's hard to make both adding types and adding operations cheap at the same time. Ordinary object orientation makes a new type cheap (one class) and a new operation expensive (edit every class). Visitor inverts it: a new operation is one file, a new type touches every visitor. Neither is wrong — you pick the axis you expect to grow. Language features like sealed types with exhaustive matching don't solve the problem, but they do make the expensive side loud, turning a silent gap into a compile error.
When should I not use the Visitor pattern?
When node types keep arriving, because every one of them costs you every visitor — and if other teams consume your visitor interface, it's a breaking change for them too. Also skip it for small hierarchies where a switch is clearer than an interface plus five accept() methods, and be careful when nodes hold data they'd rather not publish, since visitors need access and the usual result is getters for everything.
Do I still need accept() and visit() in a modern language?
Often not. TypeScript's discriminated unions with a never guard, C# switch expressions over sealed records, Java 21's sealed interfaces with pattern matching, and C++'s std::variant plus std::visit all dispatch on the node type and additionally verify at compile time that you handled every case — which the classic pattern cannot do. Keep accept()/visit() for open hierarchies, where code outside your control contributes node types you can't enumerate in a switch.
Who should drive the traversal — the nodes or the visitor?
Both work, and the difference matters more than it looks. If each node's accept() visits its children, visitors are shorter but every walk is depth-first in the order the nodes chose. If the visitor (or a separate walker) drives, you can prune subtrees, change the order, or visit a node before and after its children — which is why go/ast.Walk lets Visit return nil to skip a branch. Whichever you choose, keep the traversal in one place: duplicated in every visitor, a nesting bug has to be fixed N times.

Finish the handbook, earn the certificate

All 23 Gang-of-Four patterns, each with a simulator you can click and code in seven languages. Free, no signup.

See all 23 patterns →
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.