Definition
A visitor represents an operation on the elements of an object structure as an object of its own, so a new operation can be defined without changing the classes of the elements it works on.
After Gamma et al., Design Patterns (1994), Visitor
01Start with one export
A document gets another job.
An article in the editor is a tree: text, images, and groups that hold other nodes. A group can be marked as a draft, and the export button must leave drafts out. The first design puts the export on each kind of node:
// The export is a method on every kind of node.
interface ArticleNode {
exportText(lines: string[]): void;
}
class TextNode implements ArticleNode {
text: string;
constructor(text: string) {
this.text = text;
}
exportText(lines: string[]): void {
lines.push(this.text);
}
}
class ImageNode implements ArticleNode {
src: string;
alt: string;
constructor(src: string, alt: string) {
this.src = src;
this.alt = alt;
}
exportText(lines: string[]): void {
if (this.alt !== '') lines.push(this.alt);
}
}
class GroupNode implements ArticleNode {
draft: boolean;
children: ArticleNode[];
constructor(draft: boolean, children: ArticleNode[]) {
this.draft = draft;
this.children = children;
}
exportText(lines: string[]): void {
if (this.draft) return;
for (const child of this.children) child.exportText(lines);
}
} Those methods are a clear first design: each rule sits beside the data it uses.
Now the editor also needs an asset inventory, so the build uploads every image, drafts included, and an image review that flags descriptions that disagree with an image’s decorative flag. Putting every new rule on every node spreads each job across the document model: each operation lands in three classes, its running state (the files seen so far, the issues found) is threaded through every call, and each has its own answer to whether a draft counts.
Visitor moves one operation into its own object, with a method for every kind of node. A node routes the call to the method for its kind; the operation owns its result and its policy for walking groups.
That trade makes another operation local to one visitor. Adding a new node kind asks every operation what that kind means. You already hand visitors to tools that walk a tree for you:
| The API | What calls your method | Who decides to go deeper |
|---|---|---|
| An ESLint rule: create() returns { CallExpression(node) {…} } | ESLint, for each node of that type, as it walks the syntax tree. | ESLint: it always walks; a key ending in :exit runs after the node’s children. |
| A Babel plugin: visitor: { Identifier(path) {…} } | Babel, for each node of that type. | Babel, unless your method calls path.skip(). |
| The TypeScript compiler API: ts.forEachChild(node, visit) | forEachChild, once for each child of one node. | Your visit function, by calling forEachChild again. |
02Keep one operation together
The node dispatches. The visitor decides what to do next.
This is the whole program the lab runs: the three kinds of node, the visitor contract, two operations, and the callers that run them over one article. In your language, follow an image node to its image method: the export reads its description, the inventory reads its source. A group only dispatches. Its visitor chooses whether to visit the children, in their stored order.
// One method for each kind of node an article is made of.
interface Visitor {
visitText(node: TextNode): void;
visitImage(node: ImageNode): void;
visitGroup(node: GroupNode): void;
}
interface ArticleNode {
accept(visitor: Visitor): void;
}
class TextNode implements ArticleNode {
text: string;
constructor(text: string) {
this.text = text;
}
accept(visitor: Visitor): void {
visitor.visitText(this);
}
}
class ImageNode implements ArticleNode {
src: string;
alt: string;
constructor(src: string, alt: string) {
this.src = src;
this.alt = alt;
}
accept(visitor: Visitor): void {
visitor.visitImage(this);
}
}
class GroupNode implements ArticleNode {
draft: boolean;
children: ArticleNode[];
constructor(draft: boolean, children: ArticleNode[]) {
this.draft = draft;
this.children = children;
}
// Dispatch only: the visitor decides whether to walk the children.
accept(visitor: Visitor): void {
visitor.visitGroup(this);
}
}
// One operation: the readable text, without unpublished drafts.
class TextExport implements Visitor {
lines: string[] = [];
visitText(node: TextNode): void {
this.lines.push(node.text);
}
visitImage(node: ImageNode): void {
if (node.alt !== '') this.lines.push(node.alt);
}
visitGroup(node: GroupNode): void {
if (node.draft) return;
for (const child of node.children) child.accept(this);
}
}
// Another: every image file, drafts included, so the build uploads them all.
class AssetInventory implements Visitor {
files: string[] = [];
visitText(_node: TextNode): void {}
visitImage(node: ImageNode): void {
this.files.push(node.src);
}
visitGroup(node: GroupNode): void {
for (const child of node.children) child.accept(this);
}
}
// The callers: a fresh visitor for each run.
function exportText(root: ArticleNode): string[] {
const visitor = new TextExport();
root.accept(visitor);
return visitor.lines;
}
function listAssets(root: ArticleNode): string[] {
const visitor = new AssetInventory();
root.accept(visitor);
return visitor.files;
}
const article = new GroupNode(false, [
new TextNode('Trail guide'),
new ImageNode('/cover.png', 'A branching trail'),
new GroupNode(true, [
new TextNode('Unpublished notes'),
new ImageNode('/chart.png', 'Elevation chart'),
]),
]);
const text = exportText(article);
const assets = listAssets(article);
console.log(`text: ${text.join(' | ')}`);
console.log(`assets: ${assets.join(' | ')}`); | Part | What it owns |
|---|---|
| Node (text, image, group) | Holds its data. accept calls the visitor’s method for its own kind, and nothing more. |
| Visitor (one operation) | Its rule for each kind of node, its running result, and whether to walk a group’s children. |
| Caller | Makes a fresh visitor, hands it to the root, and takes the result. |
What production operations addIDs, a review, and one copy of each source
The same visitor with three operations and the details a real editor needs. Every node has an
ID, so the image review can name the image it flags. A decorative image says nothing in the
export, and the review flags a decorative image with a description, or an informative one
without. The inventory lists each exact source once, in the order first seen: a Set answers “seen?” and the array is the result. A group keeps a frozen copy of its children, so a later change to the
caller’s array cannot reorder it.
// One method for each kind of node an article is made of.
export interface Visitor {
visitText(node: TextNode): void;
visitImage(node: ImageNode): void;
visitGroup(node: GroupNode): void;
}
export interface ArticleNode {
readonly id: string;
accept(visitor: Visitor): void;
}
export class TextNode implements ArticleNode {
readonly id: string;
readonly text: string;
constructor(id: string, text: string) {
this.id = id;
this.text = text;
}
accept(visitor: Visitor): void {
visitor.visitText(this);
}
}
export class ImageNode implements ArticleNode {
readonly id: string;
readonly src: string;
readonly alt: string;
readonly decorative: boolean;
constructor(id: string, src: string, alt: string, decorative: boolean) {
this.id = id;
this.src = src;
this.alt = alt;
this.decorative = decorative;
}
accept(visitor: Visitor): void {
visitor.visitImage(this);
}
}
export class GroupNode implements ArticleNode {
readonly id: string;
readonly draft: boolean;
readonly children: readonly ArticleNode[];
constructor(id: string, draft: boolean, children: readonly ArticleNode[]) {
this.id = id;
this.draft = draft;
// A copy, so a later change to the caller's array cannot reorder it.
this.children = Object.freeze([...children]);
}
// Dispatch only: the visitor decides whether to walk the children.
accept(visitor: Visitor): void {
visitor.visitGroup(this);
}
}
// The readable text: text, and the descriptions of informative images.
// Draft groups are left out; decorative images say nothing.
export class TextExport implements Visitor {
readonly lines: string[] = [];
visitText(node: TextNode): void {
this.lines.push(node.text); // Empty text is still a line.
}
visitImage(node: ImageNode): void {
if (!node.decorative && node.alt !== '') this.lines.push(node.alt);
}
visitGroup(node: GroupNode): void {
if (node.draft) return;
for (const child of node.children) child.accept(this);
}
}
// Every image file, drafts included, each exact source once, in the order
// first seen.
export class AssetInventory implements Visitor {
readonly files: string[] = [];
private readonly seen = new Set<string>();
visitText(_node: TextNode): void {}
visitImage(node: ImageNode): void {
if (this.seen.has(node.src)) return;
this.seen.add(node.src);
this.files.push(node.src);
}
visitGroup(node: GroupNode): void {
for (const child of node.children) child.accept(this);
}
}
// Images whose description and decorative flag disagree, drafts included.
export class ImageReview implements Visitor {
readonly issues: string[] = [];
visitText(_node: TextNode): void {}
visitImage(node: ImageNode): void {
if (node.decorative && node.alt !== '') {
this.issues.push(`decorative-alt:${node.id}`);
} else if (!node.decorative && node.alt === '') {
this.issues.push(`missing-alt:${node.id}`);
}
}
visitGroup(node: GroupNode): void {
for (const child of node.children) child.accept(this);
}
}
// The callers: a fresh visitor for each run, so one run's results never
// reach the next.
export function exportText(root: ArticleNode): string[] {
const visitor = new TextExport();
root.accept(visitor);
return visitor.lines;
}
export function listAssets(root: ArticleNode): string[] {
const visitor = new AssetInventory();
root.accept(visitor);
return visitor.files;
}
export function reviewImages(root: ArticleNode): string[] {
const visitor = new ImageReview();
root.accept(visitor);
return visitor.issues;
} The examples assume trusted, finite, acyclic documents. Validate external structure, allowed values, size, and nesting before traversal. Recursive calls can exhaust the runtime stack on deep input. TypeScript can represent a node listed under two groups, or a cycle; these operations visit each occurrence and do not detect cycles.
An unexpected exception stops the run without a report. These operations only read, so the input survives. If a future visitor writes files or fetches data, choose how errors travel, whether the run can be cancelled, and whether a partial result is useful.
03Predict who skips the draft
Should the node or the operation decide about drafts?
Experiment
One article, two operations
// GroupNode's accept (lines 42–44) becomes this, so that no
// operation can ever leak an unpublished draft.
accept(visitor: Visitor): void {
if (!this.draft) visitor.visitGroup(this);
} Reading the TypeScriptaccept, classes, and readonly
Each class’s accept calls the visitor method for that node. The node’s runtime
type selects visitImage, which receives an ImageNode and can read
its fields without checking a tag.
A discriminated-union switch could implement each operation too. This example uses accept so each operation is an object implementing the same Visitor contract. When that interface grows, TypeScript reports every visitor
class missing the new method: “Class 'TextExport' incorrectly implements interface
'Visitor'”.
A method can ignore its node, as the inventory’s visitText does; the leading
underscore in _node says so to the reader and to lint rules. In the production
version, readonly fields are compile-time rules, not a deep runtime freeze. These
visitors read the nodes and keep their mutable result in their own instances.
04Meet it in your language
Your tools already call you back by kind.
An ESLint rule is a visitor.
Its create() returns methods keyed by node type, and ESLint walks the syntax tree
and calls the method for each node’s type. A rule names only the node types it needs, since
ESLint owns the walk: unlike this lesson’s Visitor, no method is required for every
kind. ESLint calls create() once for each file it lints, so that is where one
run’s state belongs. The bug people meet is state declared beside the rule instead:
import type { Rule } from 'eslint';
// The title of test('…', …), or null for any other node.
function testTitle(node: Rule.Node): string | null {
if (node.type !== 'CallExpression') return null;
if (node.callee.type !== 'Identifier' || node.callee.name !== 'test') return null;
const [first] = node.arguments;
return first?.type === 'Literal' && typeof first.value === 'string' ? first.value : null;
}
const meta: Rule.RuleMetaData = {
type: 'problem',
messages: { repeated: 'Another test in this file is called "{{title}}".' },
};
// Before: the titles live beside the rule, so they outlive one file.
const seenTitles = new Set<string>();
export const uniqueTitlesBefore: Rule.RuleModule = {
meta,
create(context) {
return {
CallExpression(node) {
const title = testTitle(node);
if (title === null) return;
if (seenTitles.has(title)) {
context.report({ node, messageId: 'repeated', data: { title } });
}
seenTitles.add(title);
},
};
},
};
// After: ESLint calls create() once per file, so state made inside it
// belongs to one file's run.
export const uniqueTitles: Rule.RuleModule = {
meta,
create(context) {
const seen = new Set<string>();
return {
CallExpression(node) {
const title = testTitle(node);
if (title === null) return;
if (seen.has(title)) {
context.report({ node, messageId: 'repeated', data: { title } });
}
seen.add(title);
},
};
},
}; The set beside the rule outlives one file, so a title used in one test file is reported in the
next. Make per-run state inside create(), the way exportText makes a
fresh TextExport for each run.
05Decide when it helps
Choose which change to make easy.
Give the code a plausible change. Follow how many places it touches when each operation is a method on every node, a visitor, or a function with a case for each kind.
| The change | A method on every node | A visitor per operation | A switch or match per operation |
|---|---|---|---|
| A new operation (a word count) | A method on every node class. | One new visitor. The nodes do not change. | One new function with a case for each kind. |
| A new kind of node (video) | One new class, with every operation’s method in it. | A method on the visitor contract, then one in every visitor. The compiler lists them. | A case in every function. A closed union lists them; a default case hides them. |
| An operation with running state (sources seen so far) | Threaded through every method’s parameters. | A field of the visitor, fresh for each run. | A local in the function, passed down its recursive calls. |
| Both keep changing | Every new operation touches every kind. | Every new kind touches every visitor. | Every new kind touches every function. |
For a small document vocabulary and a few operations, separate functions with a switch can be clearer. Each function can keep its accumulator local and choose a branch for every node kind. Visitor groups one operation’s handlers and result behind a shared per-node contract, so callers can depend on that extension point without depending on a switch’s implementation.
Either design can add an operation without changing the nodes. The cost is at a vocabulary change: every visitor must implement a policy for the new kind, while every operation switch must add a branch. Required visitor methods expose missing implementations after their contract changes; neither check proves that the chosen behavior is correct. No design makes both changes cheap; that tension has a name, the expression problem.
What the pattern leaves for you to decide:
- Who walks. Here each visitor walks its groups, so each operation can choose. A library can own the walk (ESLint) and let you skip; nodes that walk impose one policy on every operation.
- Before or after. These visitors act on a group before its children. A total
that needs its children’s answers acts after; ESLint’s
:exitkeys exist for that. - How long state lives. A fresh visitor per run keeps one export’s lines out of the next report. A visitor reused directly accumulates.
- Required methods or defaults. Requiring a method for every kind makes a new kind a compile error in every visitor. A default (do nothing, walk on, or fail) keeps old visitors compiling, and is also how a new kind gets silently ignored.
- Results. These visitors collect into fields. A visitor whose methods return a value suits operations that combine children’s answers, like a total.
The rule for what goes where: nodes keep their data and dispatch; each visitor keeps one operation’s rules, its running state, and its decision to descend; the caller makes a fresh visitor and takes the result.
Its neighbours: Composite gives individuals and groups one shared operation; Visitor puts separate operations over supported node kinds, and works over a flat set of variants too. Interpreter puts an expression’s meaning in its node types; here nodes route a call and each operation lives outside them. Iterator walks a structure without dispatching on kind, and discriminated unions select a handler with a switch. Strategy supplies one interchangeable policy; Visitor gives one operation a method for each supported shape.
06Make the call
More operations, or more kinds of node?
Before you open the answer below, say it in your own words: what does a node decide, what does a visitor decide, and which change does the pattern make cheap at the cost of which other?
One way to say itWho decides what, and which change gets cheap
A node decides only which method to call: its own kind’s. A visitor is one operation: it decides what each kind of node means for that operation, keeps the operation’s running result, and decides whether to walk into a group. Keep that decision in the visitor, because operations disagree: the export skips drafts and the inventory needs them, and a node that skipped drafts itself would make the export’s rule everyone’s. Make a fresh visitor for each run so results do not carry over. The pattern makes a new operation cheap, one new class and no node edited, and makes a new kind of node expensive, a method in every visitor. Use it when the kinds are stable and the operations keep coming; when the kinds keep changing, keep the operations on the nodes, and resist a do-nothing default that turns a missing case into silence.
Practice in the lab
A choir’s score editor stores each part as a tree of notes, rests and sections, and a section can be marked to repeat. Like the lesson’s article, the kinds of node rarely change while new operations keep arriving: the editor already reports how long a part plays, and singers now want their voice’s range checked. Each level starts from the previous level’s finished code, so you can begin at any of them.
- VISITOR-101 (easy) Flag the notes a voice can’t sing
- VISITOR-102 (medium) A chord slips past both reports Pro
- VISITOR-103 (hard) Review: grace notes and a status bar Pro