Skip to content
aipatterns.dev

Design patternBehavioral Patterns

Visitor

Add new operations over a fixed set of node types without editing those types, by routing each node to a type-specific method on a visitor object.

Complexity
High
Seen in the wild
Low
Also known as
Double Dispatch

Intent

Separate operations from the object structure they run over. Each element type gets one accept method that calls back the matching method on a visitor, so a new operation is a new visitor class and the element classes stay untouched. The pattern makes new operations cheap and new element types expensive.

Forces

  • A stable set of node types vs a growing list of operations: an expression tree keeps the same four node kinds while evaluation, pretty-printing, linting and serialization pile up.
  • Cohesive operations vs cohesive types: putting evaluate, explain and toJson on every node scatters each operation across many classes.
  • Type-specific behavior vs no type checks: without help from the language, picking behavior by concrete type means instanceof chains that miss new types silently.
  • Encapsulation vs access: a visitor needs to read each element's data, which pushes elements to expose more than they otherwise would.

Therefore

Give every element an accept(visitor) method that calls visitor.visitX(this), and put each operation in its own visitor class with one method per element type.

The two calls make up double dispatch: accept resolves the element's type, and visitX resolves the operation. The visitor interface lists every element type, so the compiler reports any visitor that misses one. In languages with sum types and exhaustive pattern matching, such as Rust, Kotlin, Scala, Swift, TypeScript discriminated unions or Java 21 sealed interfaces, a switch over the type gives you the same guarantee with no accept methods at all.

Structure

Diagram source (Mermaid)
classDiagram
  class Policy {
    <<interface>>
    +accept(v) R
  }
  class HasRole {
    -String role
    +accept(v) R
  }
  class AllOf {
    -List children
    +accept(v) R
  }
  class Not {
    -Policy inner
    +accept(v) R
  }
  class PolicyVisitor {
    <<interface>>
    +visitHasRole(p) R
    +visitAllOf(p) R
    +visitNot(p) R
  }
  class Evaluator {
    -Set roles
    +visitHasRole(p) bool
  }
  class Explainer {
    +visitHasRole(p) String
  }
  Policy <|.. HasRole
  Policy <|.. AllOf
  Policy <|.. Not
  PolicyVisitor <|.. Evaluator
  PolicyVisitor <|.. Explainer
  Policy ..> PolicyVisitor : accept calls visitX

Participants

  • Element (Policy): declares accept(visitor).
  • Concrete elements (HasRole, AllOf, AnyOf, Not): each implements accept by calling its own visitX method. They contain no operation logic.
  • Visitor (PolicyVisitor): one method per concrete element type. Generic over the result type where the language allows it.
  • Concrete visitors (Evaluator, Explainer): one operation each, with a case for every element type.
  • Object structure: the tree or collection being visited. Composite visitors recurse into children by calling child.accept(this).

Use it when

  • The element hierarchy is stable and owned by you, and new operations over it arrive often, as with ASTs, query plans, document models and rule trees.
  • Operations need different behavior per node type, and you want the compiler to flag a missing case.
  • Several unrelated operations would otherwise bloat every element class with methods that belong to other layers, such as rendering, persistence or metrics.
  • An operation needs to accumulate state across a whole traversal, such as collecting every referenced role or counting nodes by kind.

Do not use it when

  • The element types are a closed set defined in your own code, and each operation visits every type once. Model the elements as a sealed or union type and write one switch/match per operation: it is shorter, keeps each operation in one function, and the compiler still reports a missing case. This covers TypeScript (discriminated unions with a never check), Rust, Kotlin, Swift, Scala and Java 21+. When the classes cannot share a union - different owners, different packages - this bullet does not apply.
  • New element types arrive more often than new operations. Every new element type forces a new method on the visitor interface and an edit to every visitor. Put the operation on the element instead, with plain polymorphism.
  • You don't own the element classes and can't add accept. Use a type switch, a lookup by type, or an adapter.
  • There is one operation. A method on each element (plain polymorphism) is simpler.
  • The structure is a flat list of one type. Use a loop or Iterator.

Application grill

Inspect first: the node set's stability (visitors assume it is frozen) and who would add node types; existing pattern matching or exhaustive switches over the same types; whether the code is compiler-shaped (ASTs, documents) where visitors are natural.

Missing pieces: none beyond the language: in languages with discriminated unions, an exhaustive switch or pattern match is the modern form and gives compiler errors on new nodes for free.

Ask before you build:

  • Scale: how often are new node types added versus new operations - visitors pay off only when operations change more often than types?
  • Performance: does dispatch overhead matter in tight AST loops - measure before choosing the shape?
  • Reliability: what does a partial visitor do for a node it does not handle - default case or type error?
  • Budget: an exhaustive switch gives compiler-enforced completeness without an interface - is the visitor interface buying anything beyond that?
  • Implementation: does the visitor carry per-node return types, and who keeps the double-dispatch glue honest?

Implementation steps

  1. Confirm the set of element types is stable and that new operations, not new types, are the expected change.
  2. Declare the visitor interface with one visitX method per concrete element. Make it generic over the return type if the language allows, to avoid mutable result fields.
  3. Add accept(visitor) to the element interface, and implement it in each element as a single call to its own visitX.
  4. Move each operation, often a Switch Statements smell over node types, into a visitor class, one method per case.
  5. Decide who traverses children. Usually the visitor recurses by calling child.accept(this), which lets it control order and short-circuit.
  6. Delete the old type checks and run the tests.

Example

An internal admin tool stores access rules as small expression trees such as "admin, or billing and not a contractor". The same tree must be evaluated against a user and explained in plain text on the settings page.

interface PolicyVisitor<R> {
  visitHasRole(p: HasRole): R;
  visitAllOf(p: AllOf): R;
  visitAnyOf(p: AnyOf): R;
  visitNot(p: Not): R;
}

interface Policy {
  accept<R>(v: PolicyVisitor<R>): R;
}

class HasRole implements Policy {
  constructor(readonly role: string) {}
  accept<R>(v: PolicyVisitor<R>): R { return v.visitHasRole(this); }
}
class AllOf implements Policy {
  constructor(readonly children: Policy[]) {}
  accept<R>(v: PolicyVisitor<R>): R { return v.visitAllOf(this); }
}
class AnyOf implements Policy {
  constructor(readonly children: Policy[]) {}
  accept<R>(v: PolicyVisitor<R>): R { return v.visitAnyOf(this); }
}
class Not implements Policy {
  constructor(readonly inner: Policy) {}
  accept<R>(v: PolicyVisitor<R>): R { return v.visitNot(this); }
}

class Evaluator implements PolicyVisitor<boolean> {
  constructor(private readonly roles: ReadonlySet<string>) {}
  visitHasRole(p: HasRole): boolean { return this.roles.has(p.role); }
  visitAllOf(p: AllOf): boolean { return p.children.every((c) => c.accept(this)); }
  visitAnyOf(p: AnyOf): boolean { return p.children.some((c) => c.accept(this)); }
  visitNot(p: Not): boolean { return !p.inner.accept(this); }
}

class Explainer implements PolicyVisitor<string> {
  visitHasRole(p: HasRole): string { return p.role; }
  visitAllOf(p: AllOf): string { return `(${p.children.map((c) => c.accept(this)).join(" AND ")})`; }
  visitAnyOf(p: AnyOf): string { return p.children.map((c) => c.accept(this)).join(" OR "); }
  visitNot(p: Not): string { return `NOT ${p.inner.accept(this)}`; }
}

const canIssueRefunds = new AnyOf([
  new HasRole("admin"),
  new AllOf([new HasRole("billing"), new Not(new HasRole("contractor"))]),
]);

console.log(canIssueRefunds.accept(new Explainer())); // admin OR (billing AND NOT contractor)
console.log(canIssueRefunds.accept(new Evaluator(new Set(["billing"])))); // true
console.log(canIssueRefunds.accept(new Evaluator(new Set(["billing", "contractor"])))); // false

Trade-offs

Gains

  • A new operation is one new class; element classes don't change.
  • Each operation's logic sits in one place instead of being spread across every element.
  • The compiler reports any visitor that misses an element type.
  • Visitors can carry state across a traversal, such as a symbol table, counters or an output buffer.

Costs

  • Adding an element type means a new method on the visitor interface and an edit to every visitor. This is the price, and it grows with each visitor you write.
  • Elements must expose the data visitors read, which weakens encapsulation.
  • The accept/visitX round trip is indirect and confuses readers who haven't seen double dispatch.
  • In languages without generic methods, such as Go, results travel through mutable fields, which is error-prone.
  • Pattern matching over a closed type does the same job with less code in most modern languages.

Relations

  • Composite is the usual structure a visitor walks; composite elements pass the visitor to their children.
  • Iterator walks a collection without caring about element types. Visitor exists for structures whose elements have different types and need different handling.
  • Command wraps one action as an object. A visitor is a family of related actions, one per element type, bundled into one object.
  • Strategy swaps one algorithm for a single type. Visitor adds an algorithm across a whole hierarchy of types.

Agent checklist

  • You checked whether the language offers sealed/union types with exhaustive matching, and Visitor is still the better fit.
  • The element hierarchy is stable and owned by you; new element types are rare.
  • Each accept is a single call to its own visitX, with no logic.
  • Every visitor handles every element type; no default case swallows new types silently.
  • Traversal order is decided in one place (usually the visitor) and covered by a test.
  • Old type checks (instanceof chains, type switches) for the moved operations are deleted, and existing tests pass.

See also

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.