GUIDE · Behavioral

Visitor

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

lesson3 sectionsruns in browserBehavioral17 asserts153 lines

At a glance

FIRST VISIT
From Session 2.2, read the table in section 0 and stop. Run its code after Session 3.4; sections 1 and 2 are Unit 9.
INTENT
Represent an operation to be performed on the elements of an object structure; define a new operation without changing the classes of the elements on which it operates.
PROBLEM
A document tree (Heading, Paragraph, Image, Section) needs render-to-HTML, render-to-Markdown, word count, link extraction... Adding each as a method on every node class bloats the nodes and smears one feature across many files.
SAY
double dispatchnew operations over a stable set of node types, without touching the nodes.
SEEN IN
Babel / TypeScript compiler AST traversal (traverse(ast, { Identifier(path) {} })); ESLint rules (objects keyed by node type); GraphQL-js visit(ast, visitor); PostCSS walkRules/walkDecls; Prettier printers (print by node type); JSON.parse(s, reviver) / JSON.stringify(v, replacer) — a callback visiting every node (-ish).
VS
Iterator yields elements and you do the work outside; Visitor dispatches on element TYPE and the work lives in the visitor. Interpreter puts the ONE operation on the nodes; Visitor pulls operations OUT — pick Visitor when ops change often, Interpreter/Composite when node types do.

The idea#

Picture a house with a fixed set of rooms. One room is a heading. One is a paragraph. One is an image. A section is a hallway that contains more rooms. You want three jobs done in that house: write the tour in HTML, write it in Markdown, and walk around counting words and pictures. You do not hire a carpenter to rebuild every room each time you invent a job. You send a visitor. Each room knows one sentence: come in. The visitor knows what to do once inside that kind of room.

That is the visitor pattern. The rooms are elements. accept(visitor) is the door. A heading's door calls visitHeading and hands over itself. A paragraph's door calls visitParagraph. An image's door calls visitImage. The visitor has one method per room type, and a concrete visitor is one whole job. An HTML renderer is one visitor. A Markdown renderer is another visitor walking the same halls. A stats visitor carries a notebook: words, images, and the heading text it has seen. The section is both a room and the object structure, the tree of the house. When a section lets the visitor in, that visitor's section method sends the same visitor through each child door.

The second hop is double dispatch. The room picks the method from its type. The visitor picks the behavior. One accept, and the pair decides the result. A new job, a link list or a word count, does not add a method to every room.

Why it matters#

The bug this pattern prevents is one feature smeared across the house. HTML lives on the heading class, the paragraph class, and the image class. Then the same three files grow a Markdown method. Then they grow a word count. The rooms bloat. A small change to "how we describe the house" becomes a tour of every file, and the rooms start knowing about jobs that are not part of being a room.

If the rooms are stable and the jobs are not, put the jobs in visitors. The nodes stay thin: their data, plus a door. You already walk trees this way. A compiler traverse takes an object keyed by node type, and the walker does not know what the rule wants. Linters and printers are the same split. The house stays. The visitor changes. A JSON reviver can turn a date string into a Date without teaching every object a new method.

Iterator is the pattern that feels close at the front door. An iterator hands you the next room and leaves the work outside, in your loop, and it does not care whether you walked into a kitchen or a closet. A visitor dispatches on the room's type, and the work lives in the visitor. Interpreter is the other neighbor. Interpreter paints the one operation onto the rooms themselves, which fits when the room types change often and that one operation is the point of the tree. Visitor pulls the jobs out of the house. Pick it when the jobs change often and the floor plan does not.

A small example#

JS
function heading(text) {  return { accept(visitor) { return visitor.heading(text); } };} function paragraph(text) {  return { accept(visitor) { return visitor.paragraph(text); } };} const shout = {  heading: (text) => text.toUpperCase(),  paragraph: (text) => text,}; const markdown = {  heading: (text) => `# ${text}`,  paragraph: (text) => text,}; const house = [heading('Notes'), paragraph('Hello')];const asShout = house.map((room) => room.accept(shout)).join(' / ');const asMd = house.map((room) => room.accept(markdown)).join('\n');

The rooms never mention shouting or Markdown. accept only opens the door. asShout is NOTES / Hello. asMd is a heading line, a newline, and Hello. A new format is a new visitor, not a new wing on the house.

What the code below is doing#

The sheet shows the same house twice, then a cousin you already call.

The first shape uses classes so the names are visible. Heading, Paragraph, Image, and Section are the rooms. Each accept calls the matching visit method with this: the room picks the door, the visitor picks the work. Section does not describe itself. Its visitor walks children by calling accept again. HtmlRenderer and MarkdownRenderer are two visitors, and Markdown did not change a room. StatsVisitor carries the notebook: headings store their text and add words, paragraphs add words, images bump a counter, and a section returns the same visitor.

The house in the asserts is a section, a heading, a paragraph, and a nested section with a smaller heading, an image, and another paragraph. HTML comes back as nested tags. Markdown comes back with a blank line between blocks. Stats reports 11 words, one image, and both heading strings. A lone image accepted by the Markdown visitor returns an image mark. Same door. The visitor decided the sentence.

The second shape is the plain-object walk compilers use. Rooms have a type. traverse calls visitor.enter if it exists, calls visitor[node.type] if it exists, then walks child arrays and objects. It does not know the job. One visitor collects the identifier names price, log, and total. Another counts room types on the way in. A third remodels in place: the identifier price becomes the literal 10, and a later visitor folds 10 * 3 into the literal 30. The call's argument stays total. Rooms nobody opened stay as they were.

The last lines are JSON.parse with a reviver, a visitor on the way back up. A key ending in At becomes a Date. The number under n stays 1. The parser owns the walk. You only said what to do in the room.

When you talk about it, say this: new operations over a stable set of node types, without touching the nodes.

Everything below is the demonstration. Same idea, written as code you can run.

JSsystem-design/design-patterns/behavioral/visitor.js · setup
'use strict';const assert = require('node:assert/strict');
00

Start here: three elements, two visitors#

JS
// Session 2.2 showed the DevTools Styles panel: pick one element and the panel walks the rules that matched// it, asking each rule the same question ("what do you set?"). Visitor is that walk, written as code: the element// is the rule the panel is looking at, the visitor is the question it asks every rule.//// Here the document is three elements and two visitors ask them questions. The element's kind picks which method// runs; the visitor supplies what that method answers. Same three elements, two visitors, two sets of answers:////   element      method called     toText returns                      wordCount returns//   heading      visitHeading      'GUILD NOTICES'                     2//   paragraph    visitParagraph    'Two quests remain open tonight.'   5//   image        visitImage        '[quest board]'                     0//// Stop here on your first visit from Session 2.2; that table is the visit. Come back after Session 3.4 to run the// code below, which builds the table and checks it. Sections 1 and 2 are the interview shapes for Unit 9.const page = [                                              // a literal three-element document  { kind: 'heading', text: 'Guild notices' },  { kind: 'paragraph', text: 'Two quests remain open tonight.' },  { kind: 'image', alt: 'quest board' },];const toText = {                                            // visitor 1: one method per element kind  visitHeading: (n) => n.text.toUpperCase(),  visitParagraph: (n) => n.text,  visitImage: (n) => `[${n.alt}]`,};const wordCount = {                                         // visitor 2: the same three method names, different answers  visitHeading: (n) => n.text.split(' ').length,  visitParagraph: (n) => n.text.split(' ').length,  visitImage: () => 0,};const methodFor = { heading: 'visitHeading', paragraph: 'visitParagraph', image: 'visitImage' };const accept = (node, visitor) => visitor[methodFor[node.kind]](node);   // the element picks the method by its kind// Two choices decide what runs: the element's kind picks the method, the visitor object supplies the body.// Two objects dispatching together is what the sections below call "double dispatch".
Verified by 2 asserts
JS
assert.deepEqual(page.map((n) => accept(n, toText)), ['GUILD NOTICES', 'Two quests remain open tonight.', '[quest board]']);assert.deepEqual(page.map((n) => accept(n, wordCount)), [2, 5, 0]);
01

Classic shape (classes, so the participant names are visible)#

JS
// ConcreteElements — each accept() dispatches to the matching visit method (double dispatch)class Heading { constructor(level, text) { this.level = level; this.text = text; } accept(v) { return v.visitHeading(this); } }class Paragraph { constructor(text) { this.text = text; } accept(v) { return v.visitParagraph(this); } }class Image { constructor(src, alt) { this.src = src; this.alt = alt; } accept(v) { return v.visitImage(this); } }class Section {                                           // composite element — still just accept()  constructor(...children) { this.children = children; }  accept(v) { return v.visitSection(this); }} class HtmlRenderer {                                      // ConcreteVisitor  visitHeading(h) { return `<h${h.level}>${h.text}</h${h.level}>`; }  visitParagraph(p) { return `<p>${p.text}</p>`; }  visitImage(i) { return `<img src="${i.src}" alt="${i.alt}">`; }  visitSection(s) { return `<section>${s.children.map((c) => c.accept(this)).join('')}</section>`; }}class MarkdownRenderer {                                  // ConcreteVisitor — new output, zero node changes  visitHeading(h) { return `${'#'.repeat(h.level)} ${h.text}`; }  visitParagraph(p) { return p.text; }  visitImage(i) { return `![${i.alt}](${i.src})`; }  visitSection(s) { return s.children.map((c) => c.accept(this)).join('\n\n'); }}class StatsVisitor {                                      // ConcreteVisitor with accumulated state  constructor() { this.words = 0; this.images = 0; this.headings = []; }  visitHeading(h) { this.headings.push(h.text); this.words += h.text.split(/\s+/).length; }  visitParagraph(p) { this.words += p.text.split(/\s+/).length; }  visitImage() { this.images += 1; }  visitSection(s) { s.children.forEach((c) => c.accept(this)); return this; }} const doc = new Section(                                  // ObjectStructure  new Heading(1, 'Release notes'),  new Paragraph('Faster builds and fewer bugs.'),  new Section(new Heading(2, 'Breaking'), new Image('/diff.png', 'diff'), new Paragraph('Node 18 dropped.')),);const stats = doc.accept(new StatsVisitor());
Verified by 6 asserts
JS
assert.equal(doc.accept(new HtmlRenderer()),  '<section><h1>Release notes</h1><p>Faster builds and fewer bugs.</p>'  + '<section><h2>Breaking</h2><img src="/diff.png" alt="diff"><p>Node 18 dropped.</p></section></section>');assert.equal(doc.accept(new MarkdownRenderer()),  '# Release notes\n\nFaster builds and fewer bugs.\n\n## Breaking\n\n![diff](/diff.png)\n\nNode 18 dropped.');assert.equal(stats.words, 2 + 5 + 1 + 3);assert.equal(stats.images, 1);assert.deepEqual(stats.headings, ['Release notes', 'Breaking']);assert.equal(new Image('a', 'b').accept(new MarkdownRenderer()), '![b](a)');   // same accept(), picked by runtime type
02

Idiomatic JS shape: plain `{ type }` AST nodes + a visitor object keyed by type (Babel/ESLint style)#

JS
const ast = {  type: 'Program',  body: [    { type: 'VariableDeclaration', id: 'total',      init: { type: 'BinaryExpression', op: '*', left: { type: 'Identifier', name: 'price' }, right: { type: 'Literal', value: 3 } } },    { type: 'CallExpression', callee: { type: 'Identifier', name: 'log' }, args: [{ type: 'Identifier', name: 'total' }] },  ],};const traverse = (node, visitor) => {                     // generic walk — knows nothing about the operations  if (!node || typeof node.type !== 'string') return;  visitor.enter?.(node);  visitor[node.type]?.(node);  for (const v of Object.values(node)) {    if (Array.isArray(v)) v.forEach((c) => traverse(c, visitor));    else if (v && typeof v === 'object') traverse(v, visitor);  }};const replace = (node, next) => { for (const k of Object.keys(node)) delete node[k]; Object.assign(node, next); }; const identifiers = [];traverse(ast, { Identifier: (n) => identifiers.push(n.name) }); const counts = {};traverse(ast, { enter: (n) => { counts[n.type] = (counts[n.type] ?? 0) + 1; } }); // Mutating visitors (what a Babel plugin does): inline a known constant, then constant-foldtraverse(ast, { Identifier: (n) => { if (n.name === 'price') replace(n, { type: 'Literal', value: 10 }); } });traverse(ast, {  BinaryExpression: (n) => {    if (n.op === '*' && n.left.type === 'Literal' && n.right.type === 'Literal') replace(n, { type: 'Literal', value: n.left.value * n.right.value });  },}); // JSON.parse's reviver is a visitor called bottom-up on every (key, value)const revived = JSON.parse('{"createdAt":"2024-01-02T00:00:00.000Z","n":1}', (k, v) => (k.endsWith('At') ? new Date(v) : v));
Verified by 6 asserts
JS
assert.deepEqual(identifiers, ['price', 'log', 'total']);assert.deepEqual(counts, { Program: 1, VariableDeclaration: 1, BinaryExpression: 1, Identifier: 3, Literal: 1, CallExpression: 1 });assert.deepEqual(ast.body[0].init, { type: 'Literal', value: 30 });assert.deepEqual(ast.body[1].args, [{ type: 'Identifier', name: 'total' }]);   // untouched nodes stay intactassert.ok(revived.createdAt instanceof Date);assert.equal(revived.n, 1);

See also