Pattern Composite
Composez des objets en structures arborescentes pour représenter des hiérarchies partie-tout. Traitez les objets individuels et les compositions de manière uniforme.
Le Problème
Vous construisez un explorateur de fichiers. Les fichiers ont une taille. Les dossiers contiennent des fichiers et d'autres dossiers. Vous devez afficher la taille totale de n'importe quel dossier, calculée récursivement à partir de son contenu.
L'approche naïve :
function getSize(item: File | Folder): number {
if (item.type === "file") {
return item.size;
}
let total = 0;
for (const child of item.children) {
if (child.type === "file") {
total += child.size;
} else {
total += getSize(child); // recursive, but type checks everywhere
}
}
return total;
}
Des vérifications de type à chaque niveau. Ajouter une nouvelle opération (compter les fichiers, trouver le plus gros, afficher l'arbre) implique d'écrire une autre fonction récursive avec la même structure if/else.
La Solution
Modélisez l'arbre comme une union discriminée. Utilisez fold pour le parcourir uniformément :
import { leaf, branch, fold } from "@pithos/core/eidos/composite/composite";
const project = branch({ name: "project", size: 0 }, [
leaf({ name: "README.md", size: 1024 }),
branch({ name: "src", size: 0 }, [
leaf({ name: "index.ts", size: 2048 }),
leaf({ name: "utils.ts", size: 512 }),
]),
branch({ name: "docs", size: 0 }, [
leaf({ name: "guide.md", size: 768 }),
]),
]);
// Total size: one fold, no type checks
const totalSize = fold(project, {
leaf: (data) => data.size,
branch: (_data, childSizes) => childSizes.reduce((a, b) => a + b, 0),
}); // 4352
// File count: same structure, different logic
const fileCount = fold(project, {
leaf: () => 1,
branch: (_data, counts) => counts.reduce((a, b) => a + b, 0),
}); // 4
Un seul pattern de parcours. Ajoutez de nouvelles opérations sans modifier l'arbre. Les tailles se recalculent automatiquement quand vous ajoutez ou supprimez des nœuds.
Démo
Un explorateur de fichiers avec des répertoires dépliables. Chaque fichier affiche sa taille, chaque dossier affiche sa taille totale calculée récursivement via fold. Ajoutez des fichiers et regardez les tailles se recalculer en remontant l'arbre. Un panneau montre l'opération fold en action.
- composite.ts
- Usage
/**
* Composite pattern operations via fold().
*
* fold() traverses the tree bottom-up: leaves emit values,
* branches reduce their children's results.
*/
import { fold } from "@pithos/core/eidos/composite/composite";
import type { FileTree, FoldStep } from "./types";
export { fold };
/** Fold a tree by summing: leaf emits a value, branch sums children. */
function sumFold(node: FileTree, leafValue: (data: FileTree["data"]) => number, branchBonus: number = 0): number {
return fold(node, {
leaf: (data) => leafValue(data),
branch: (_data, children) => branchBonus + children.reduce((a, b) => a + b, 0),
});
}
export function computeSize(node: FileTree): number {
return sumFold(node, (data) => data.size);
}
export function countFiles(node: FileTree): number {
return sumFold(node, () => 1);
}
export function countFolders(node: FileTree): number {
return sumFold(node, () => 0, 1);
}
export function traceFold(node: FileTree, depth: number = 0): FoldStep[] {
if (node.type === "leaf") {
return [{ name: node.data.name, type: "leaf", result: node.data.size, depth }];
}
const childSteps = node.children.flatMap((child) => traceFold(child, depth + 1));
const total = computeSize(node);
return [...childSteps, { name: node.data.name, type: "branch", result: total, depth }];
}
import { useState, useCallback } from "react";
import { createInitialTree } from "@/data/initial-tree";
import { computeSize, countFiles, countFolders, traceFold } from "@/lib/composite";
import { addFileToTree } from "@/lib/tree-ops";
export function useFileExplorer() {
const [tree, setTree] = useState(createInitialTree);
const [highlightedNode, setHighlightedNode] = useState<string | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [modalClosing, setModalClosing] = useState(false);
const [mobileTab, setMobileTab] = useState<"explorer" | "fold">("explorer");
const totalSize = computeSize(tree);
const fileCount = countFiles(tree);
const folderCount = countFolders(tree);
const foldSteps = traceFold(tree);
const handleAddFile = useCallback((folderPath: string[], name: string, size: number): boolean => {
const result = addFileToTree(tree, folderPath.slice(1), { name, size });
if (!result) return false;
setTree(result);
setHighlightedNode(name);
setTimeout(() => setHighlightedNode(null), 1500);
return true;
}, [tree]);
const handleReset = useCallback(() => {
setTree(createInitialTree());
setHighlightedNode(null);
}, []);
const closeModal = useCallback(() => {
setModalClosing(true);
setTimeout(() => { setShowAddModal(false); setModalClosing(false); }, 200);
}, []);
return {
tree, highlightedNode, totalSize, fileCount, folderCount, foldSteps,
showAddModal, setShowAddModal, modalClosing, closeModal,
mobileTab, setMobileTab,
handleAddFile, handleReset,
};
}
Analogie
Un organigramme d'entreprise. Les départements contiennent des équipes, les équipes contiennent des personnes. Quand vous demandez "combien d'employés ?", peu importe si vous interrogez un département, une équipe ou un individu : la question fonctionne de la même manière à chaque niveau.
Quand l'Utiliser
- Représenter des données hiérarchiques (fichiers, organigrammes, composants UI, menus)
- Appliquer des opérations uniformément aux feuilles et aux branches
- Construire des structures récursives avec la sécurité des types
- Besoin de tailles, comptages ou agrégations qui se propagent dans l'arbre
Quand NE PAS l'Utiliser
Si vos données sont plates (une simple liste), ne les forcez pas dans un arbre. Composite ajoute de la complexité qui ne se justifie qu'avec des structures véritablement hiérarchiques.