Visitor Pattern
Define new operations on a structure's elements without modifying their types.
The Problemβ
You're building an email builder. Each block (header, text, image, button, divider) needs multiple renderings: visual preview, HTML export, plain text, accessibility audit. The naive approach: one giant function with nested conditionals for every combination of block type Γ rendering.
function render(block: EmailBlock, format: "html" | "text" | "audit"): string {
if (format === "html") {
if (block instanceof HeaderBlock) return `<h1>${block.text}</h1>`;
if (block instanceof TextBlock) return `<p>${block.content}</p>`;
if (block instanceof ImageBlock) return `<img src="${block.src}" />`;
// ... every block Γ every format
}
if (format === "text") {
if (block instanceof HeaderBlock) return block.text;
// ... again
}
// ... again
}
Every new rendering means touching this function. Every new block type means adding cases everywhere. It doesn't scale.
The Solutionβ
Discriminated union + switch. Each "visitor" is just a function. TypeScript narrows the type in each case branch and checks exhaustiveness at compile time.
type EmailBlock =
| { type: "header"; text: string; level: 1 | 2 | 3 }
| { type: "text"; content: string }
| { type: "image"; src: string; alt: string }
| { type: "button"; label: string; url: string }
| { type: "divider" };
// "Visitor 1": HTML export
const toHtml = (block: EmailBlock): string => {
switch (block.type) {
case "header": return `<h${block.level}>${block.text}</h${block.level}>`;
case "text": return `<p>${block.content}</p>`;
case "image": return `<img src="${block.src}" alt="${block.alt}" />`;
case "button": return `<a href="${block.url}">${block.label}</a>`;
case "divider": return `<hr />`;
}
};
// "Visitor 2": plain text
const toPlainText = (block: EmailBlock): string => {
switch (block.type) {
case "header": return block.text;
case "text": return block.content;
case "image": return block.alt ? `[Image: ${block.alt}]` : `[Image]`;
case "button": return `[${block.label}: ${block.url}]`;
case "divider": return "---";
}
};
// Same data, different "visitors"
const blocks: EmailBlock[] = [/* ... */];
blocks.map(toHtml); // HTML strings
blocks.map(toPlainText); // plain text strings
Adding a new rendering? Write a new function. Adding a new block type? Add a variant to the union, TypeScript flags every switch that doesn't handle it.
This solution doesn't use Pithos. That's the point.
In TypeScript, discriminated unions + switch are the Visitor pattern. Eidos exports a @deprecated visit() function that exists only to guide you here.
Live Demoβ
An email builder with 5 block types. Compose your email, then switch between 4 visitors: Preview (visual rendering), HTML (generated code), Plain Text (buttons become [Click here: url]), and Accessibility Audit (flags images without alt text). Same data, different rendering.
- visitors.ts
- Usage
/**
* Visitor functions for the email block AST.
*
* Each visitor traverses the same discriminated union (EmailBlock)
* but produces a different output: HTML, plain text, or audit results.
*/
import type { EmailBlock, AuditResult } from "./types";
function escapeHtml(str: string): string {
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
/** Visitor 1: HTML export */
export function toHtml(block: EmailBlock): string {
switch (block.type) {
case "header":
return `<h${block.level}>${escapeHtml(block.text)}</h${block.level}>`;
case "text":
return `<p>${escapeHtml(block.content)}</p>`;
case "image":
return `<img src="${escapeHtml(block.src)}" alt="${escapeHtml(block.alt)}" />`;
case "button":
return `<a href="${escapeHtml(block.url)}" style="display:inline-block;padding:12px 24px;background:#1e293b;color:#fff;border-radius:6px;text-decoration:none">${escapeHtml(block.label)}</a>`;
case "divider":
return `<hr />`;
}
}
/** Visitor 2: Plain text export */
export function toPlainText(block: EmailBlock): string {
switch (block.type) {
case "header":
return block.level === 1
? `${"=".repeat(block.text.length)}\n${block.text}\n${"=".repeat(block.text.length)}`
: `${block.text}\n${"-".repeat(block.text.length)}`;
case "text":
return block.content;
case "image":
return block.alt ? `[Image: ${block.alt}]` : `[Image]`;
case "button":
return `[${block.label}: ${block.url}]`;
case "divider":
return "---";
}
}
/** Visitor 3: Accessibility audit */
export function audit(block: EmailBlock, index: number): AuditResult {
switch (block.type) {
case "header":
if (!block.text.trim()) return { block, index, severity: "error", message: "Empty heading" };
if (block.text.length > 80) return { block, index, severity: "warning", message: "Heading too long (>80 chars)" };
return { block, index, severity: "pass", message: "Heading OK" };
case "text":
if (!block.content.trim()) return { block, index, severity: "error", message: "Empty text block" };
return { block, index, severity: "pass", message: "Text OK" };
case "image":
if (!block.alt.trim()) return { block, index, severity: "error", message: "Missing alt text on image" };
return { block, index, severity: "pass", message: `Alt text: "${block.alt}"` };
case "button":
if (!block.label.trim()) return { block, index, severity: "error", message: "Button has no label" };
if (block.label.toLowerCase() === "click here") return { block, index, severity: "warning", message: "Vague button label (\"click here\")" };
return { block, index, severity: "pass", message: "Button OK" };
case "divider":
return { block, index, severity: "pass", message: "Decorative divider" };
}
}
import { useState, useCallback } from "react";
import { DEFAULT_BLOCKS } from "@/data/blocks";
import type { EmailBlock, VisitorKey } from "@/lib/types";
export function useEmailBuilder() {
const [blocks, setBlocks] = useState<EmailBlock[]>(DEFAULT_BLOCKS);
const [visitor, setVisitor] = useState<VisitorKey>("preview");
const [mobileTab, setMobileTab] = useState<"blocks" | "output">("blocks");
const addBlock = useCallback((block: EmailBlock) => {
setBlocks((prev) => [...prev, block]);
}, []);
const removeBlock = useCallback((index: number) => {
setBlocks((prev) => prev.filter((_, i) => i !== index));
}, []);
const reorderBlock = useCallback((fromIndex: number, toIndex: number) => {
setBlocks((prev) => {
const next = [...prev];
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
return next;
});
}, []);
const moveBlock = useCallback((index: number, dir: -1 | 1) => {
setBlocks((prev) => {
const next = [...prev];
const target = index + dir;
if (target < 0 || target >= next.length) return prev;
[next[index], next[target]] = [next[target], next[index]];
return next;
});
}, []);
const resetBlocks = useCallback(() => {
setBlocks(DEFAULT_BLOCKS);
}, []);
return {
blocks,
visitor,
setVisitor,
mobileTab,
setMobileTab,
addBlock,
removeBlock,
reorderBlock,
moveBlock,
resetBlocks,
};
}
APIβ
- visitor
@deprecatedβ use discriminated unions with switch
Related
- Eidos: Design Patterns Module All 23 GoF patterns reimagined for functional TypeScript
- Why FP over OOP? The philosophy behind Eidos: no classes, no inheritance, just functions and types
- Zygos Result Wrap your switch results in
Resultfor typed error handling