Separating Operations from Object Structure
The Visitor pattern addresses a specific tension: you have a stable hierarchy of element classes (say, Circle, Square, and Triangle in a shape library) but you frequently need to add new operations over that hierarchy (calculate area, render to SVG, export to JSON, compute a bounding box). Adding each new operation as a method on every element class means constantly modifying and recompiling those classes, which violates the Open/Closed Principle if the hierarchy is meant to be stable. Visitor flips the relationship: each operation becomes a separate Visitor class with a visit method per element type, and each element class gets a single, permanent accept(visitor) method that simply calls the right visit method on the visitor. New operations are added by writing new Visitor classes, leaving the element hierarchy completely untouched.
Cricket analogy: A stadium's ground staff doesn't rebuild the pitch every time a new kind of assessment is needed; instead specialist inspectors (a pitch visitor, a drainage visitor, a boundary-rope visitor) each visit the same fixed ground and perform their own specific evaluation.
Double Dispatch: How Visitor Actually Works
The mechanism that makes Visitor work is double dispatch. A single accept(visitor) call on an element resolves to the correct visit method for that element's concrete type through two virtual method calls: first, element.accept(visitor) is dispatched based on the element's runtime type (Circle vs Square), and inside that method, it calls visitor.visitCircle(this) (or visitor.visitSquare(this)), which is dispatched based on the visitor's runtime type. Most object-oriented languages only support single dispatch natively — a method call resolves based on the runtime type of just one object (the receiver) — so Visitor is essentially a technique for simulating double dispatch using two chained single-dispatch calls, letting the correct combination of (element type, operation type) be selected without an explicit instanceof or type-switch chain.
Cricket analogy: When an umpire signals a decision, the exact ruling depends on both the type of dismissal claimed (LBW vs caught) and which specific umpire is making the call (on-field vs third umpire) — two independent factors combining to determine the final outcome, just as double dispatch depends on both element type and visitor type.
interface Shape {
accept(visitor: ShapeVisitor): void;
}
interface ShapeVisitor {
visitCircle(circle: Circle): void;
visitSquare(square: Square): void;
}
class Circle implements Shape {
constructor(public radius: number) {}
accept(visitor: ShapeVisitor): void {
visitor.visitCircle(this);
}
}
class Square implements Shape {
constructor(public side: number) {}
accept(visitor: ShapeVisitor): void {
visitor.visitSquare(this);
}
}
class AreaVisitor implements ShapeVisitor {
totalArea = 0;
visitCircle(circle: Circle): void {
this.totalArea += Math.PI * circle.radius ** 2;
}
visitSquare(square: Square): void {
this.totalArea += square.side ** 2;
}
}
const shapes: Shape[] = [new Circle(2), new Square(3)];
const areaVisitor = new AreaVisitor();
shapes.forEach((shape) => shape.accept(areaVisitor));
console.log(areaVisitor.totalArea);When Visitor Fits — and When It Doesn't
Visitor shines when the element hierarchy is stable but operations proliferate — classic examples include AST (abstract syntax tree) processing in compilers, where node types like BinaryExpr, Literal, and FunctionCall rarely change but you constantly add new passes such as type checking, constant folding, and code generation. The tradeoff is exactly inverted from typical polymorphic design: adding a new element type (a new AST node kind) becomes expensive, since every existing Visitor implementation must be updated with a new visit method, while adding a new operation is cheap. This makes Visitor a poor fit when the element hierarchy changes often but the set of operations is stable — in that scenario, ordinary polymorphic methods on the element classes are simpler and require less ceremony.
Cricket analogy: The laws of cricket (the 'element hierarchy' of dismissal types — bowled, caught, LBW, run out) change extremely rarely, which is why the MCC can keep adding new interpretive guidance (new 'visitors') like DRS protocols without touching the fundamental dismissal types themselves.
Compilers are the textbook use case for Visitor: the AST node types are fixed once the language grammar is defined, but passes like type checking, optimization, and code generation are added continuously over the project's life.
If you expect to frequently add new element/node types, avoid Visitor — every new type requires updating every existing Visitor implementation, which can mean touching many files for a single new node kind. In that situation, prefer ordinary virtual methods on the elements themselves.
- Visitor moves an operation out of the element classes and into a separate Visitor class.
- Each element implements accept(visitor), which calls the matching visit method on the visitor.
- The pattern relies on double dispatch to select behavior based on both element type and visitor type.
- Visitor fits best when the element hierarchy is stable but new operations are added often, e.g., AST processing in compilers.
- Adding a new element type is expensive (every visitor needs updating); adding a new operation is cheap (just write a new visitor).
- Visitor is a poor fit when element types change frequently but operations stay stable.
- Double dispatch avoids explicit instanceof/type-switch chains by using two chained polymorphic calls.
Practice what you learned
1. What problem does the Visitor pattern primarily solve?
2. What method does each element class implement in the Visitor pattern?
3. What technique does Visitor rely on to select the correct behavior for a given (element type, operation) pair?
4. In which scenario is Visitor the best fit?
5. What is the main cost of adding a new element type when using Visitor?
Was this page helpful?
You May Also Like
Iterator Pattern
A behavioral pattern that provides a way to access elements of an aggregate object sequentially without exposing its underlying representation.
Chain of Responsibility Pattern
A behavioral pattern that passes a request along a chain of handlers until one of them handles it, decoupling senders from receivers.
Memento Pattern
A behavioral pattern that captures and externalizes an object's internal state so it can be restored later, without violating encapsulation.
Related Reading
Related Study Notes in Software Engineering
Browse all study notesMicroservices Study Notes
Software Architecture · 30 topics
Software EngineeringTesting & TDD Study Notes
Software Testing · 30 topics
Software EngineeringSoftware Engineering Study Notes
Python · 40 topics
Software EngineeringGit & Version Control Study Notes
Bash · 40 topics
Software EngineeringSystem Design Study Notes
Architecture · 40 topics