Aller au contenu principal

Pattern Visitor

Définissez de nouvelles opérations sur les éléments d'une structure sans modifier leurs types.


Le Problème​

Vous construisez un éditeur d'emails. Chaque bloc (header, texte, image, bouton, séparateur) nécessite plusieurs rendus : aperçu visuel, export HTML, texte brut, audit d'accessibilité. L'approche naïve : une fonction géante avec des conditions imbriquées pour chaque combinaison type de bloc × rendu.

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}" />`;
// ... chaque bloc × chaque format
}
if (format === "text") {
if (block instanceof HeaderBlock) return block.text;
// ... encore
}
// ... encore
}

Chaque nouveau rendu oblige à toucher cette fonction. Chaque nouveau type de bloc oblige à ajouter des cas partout. Ça ne passe pas à l'échelle.


La Solution​

Discriminated union + switch. Chaque "visitor" est simplement une fonction. TypeScript affine le type dans chaque branche du case et vérifie l'exhaustivité à la compilation.

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" : export HTML
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" : texte brut
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 "---";
}
};

// Mêmes données, différents "visitors"
const blocks: EmailBlock[] = [/* ... */];
blocks.map(toHtml); // chaînes HTML
blocks.map(toPlainText); // chaînes texte brut

Ajouter un nouveau rendu ? Écrivez une nouvelle fonction. Ajouter un nouveau type de bloc ? Ajoutez un variant à l'union, TypeScript signale chaque switch qui ne le gère pas.

Absorbé par le Langage

Cette solution n'utilise pas Pithos. C'est justement le point.

En TypeScript, les discriminated unions + switch sont le pattern Visitor. Eidos exporte une fonction @deprecated visit() qui n'existe que pour vous guider ici.


Démo​

Un éditeur d'emails avec 5 types de blocs. Composez votre email, puis basculez entre 4 visitors : Preview (rendu visuel), HTML (code généré), Plain Text (les boutons deviennent [Click here: url]), et Accessibility Audit (signale les images sans texte alt). Mêmes données, rendu différent.

Code
/**
* 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

/** 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" };
}
}
Result

API​

  • visitor @deprecated — utilisez les discriminated unions avec switch

Liens connexes