03 - Strategy : changer de comportement a la volee
Ce que tu vas apprendre
- Le pattern Strategy avec des fonctions (pas des classes)
- La différence entre Strategy et un simple if/else
- Quand basculer d'un if/else vers une Strategy
Prerequisites
Avoir lu l'introduction de la serie.
Le problème
Tu as un calcul qui varie selon le contexte. Tarification standard, tarification en gros, tarification VIP. Première reaction : un switch ou un if/else.
typescriptfunction calculateTotal(items: CartItem[], type: "standard" | "bulk" | "vip"): number {
if (type === "standard") {
return items.reduce((sum, i) => sum + i.price * i.qty, 0);
} else if (type === "bulk") {
return items.reduce((sum, i) => sum + i.price * i.qty * (i.qty > 10 ? 0.9 : 1), 0);
} else if (type === "vip") {
return items.reduce((sum, i) => sum + i.price * i.qty * 0.85, 0);
}
throw new Error(`Unknown pricing type: ${type}`);
}
Ca fonctionne. Mais ajoute "tarification etudiante", "tarification Black Friday", "tarification partenaire"... la fonction grossit. Chaque modification touche le meme fichier. Si un dev ajoute une stratégie pendant qu'un autre en modifie une, merge conflict.
La solution : Strategy
Extraire chaque algorithme dans sa propre... fonction. En TypeScript, une Strategy c'est souvent juste un type de fonction :
typescripttype PricingStrategy = (items: CartItem[]) => number;
const standardPricing: PricingStrategy = (items) =>
items.reduce((sum, i) => sum + i.price * i.qty, 0);
const bulkPricing: PricingStrategy = (items) =>
items.reduce((sum, i) => sum + i.price * i.qty * (i.qty > 10 ? 0.9 : 1), 0);
const vipPricing: PricingStrategy = (items) =>
items.reduce((sum, i) => sum + i.price * i.qty * 0.85, 0);
function calculateTotal(items: CartItem[], strategy: PricingStrategy): number {
return strategy(items);
}
L'appelant choisit la stratégie :
typescriptconst total = calculateTotal(cart.items, vipPricing);
Ajouter une nouvelle stratégie ? Un nouveau fichier, une nouvelle fonction. Zero modification du code existant. C'est le Open/Closed Principle (le O de SOLID) en action.
Strategy avec des classes (quand c'est justifie)
Parfois la stratégie a besoin de configuration ou d'état. Dans ce cas, une classe a plus de sens :
typescriptinterface ShippingCalculator {
calculate(weight: number, distance: number): number;
estimatedDays(distance: number): number;
}
class StandardShipping implements ShippingCalculator {
calculate(weight: number, distance: number): number {
return weight * 0.5 + distance * 0.01;
}
estimatedDays(distance: number): number {
return Math.ceil(distance / 500);
}
}
class ExpressShipping implements ShippingCalculator {
constructor(private surchargeRate: number = 2.5) {}
calculate(weight: number, distance: number): number {
return (weight * 0.5 + distance * 0.01) * this.surchargeRate;
}
estimatedDays(_distance: number): number {
return 1; // toujours le lendemain
}
}
class FreeShipping implements ShippingCalculator {
calculate(_weight: number, _distance: number): number {
return 0;
}
estimatedDays(distance: number): number {
return Math.ceil(distance / 300);
}
}
L'interface ShippingCalculator a deux méthodes. Une simple fonction ne suffirait pas (enfin, tu pourrais retourner un objet, mais une classe avec état est plus lisible ici).
Exemple réel : les adaptateurs d'images de paltemps.fr
Sur paltemps.fr, on cherche des images via plusieurs APIs. Unsplash, Pexels, Pixabay. Chaque API est un adaptateur (on en parle dans l'article sur le pattern Adapter), mais le choix de quel adaptateur utiliser a runtime, c'est le pattern Strategy.
typescriptinterface ImageSearchPort {
search(query: string, page?: number): Promise<ImageResult[]>;
}
// Quelque part dans la config :
const imageSearchStrategies: Record<string, ImageSearchPort> = {
unsplash: new UnsplashAdapter(config.unsplashKey),
pexels: new PexelsAdapter(config.pexelsKey),
pixabay: new PixabayAdapter(config.pixabayKey),
};
// A l'usage :
async function searchImages(query: string, provider: string): Promise<ImageResult[]> {
const strategy = imageSearchStrategies[provider];
if (!strategy) throw new Error(`Unknown provider: ${provider}`);
return strategy.search(query);
}
Adapter + Strategy travaillent ensemble. L'Adapter traduit l'API externe. La Strategy permet de choisir quel adaptateur utiliser. Deux patterns, un seul problème résolu.
Strategy vs if/else : quand basculer ?
Ma regle perso :
- 2 cas fixes qui ne bougeront pas :
if/else. Pas besoin d'une abstraction. - 3+ cas : Strategy. Le if/else commence a devenir long et chaque ajout augmente le risque de regression.
- Le nombre de cas va grandir : Strategy, meme si tu n'as que 2 cas aujourd'hui. Tu sais deja que ca va bouger.
- L'appelant doit choisir le comportement : Strategy. C'est la qu'il brille.
- Le choix est interne et ne change jamais : garde le if/else. Strategy serait du sur-engineering.
Un if/else avec 8 branches dans une fonction de 200 lignes ? C'est un signal clair. Chaque branche devrait etre une Strategy dans son propre fichier, testable indépendamment.
Le piège : la Registry
Des que tu as plusieurs stratégies, tu vas vouloir une "registry" pour les indexer par nom. Attention, ca peut devenir un Service Locator deguise (j'en parle dans l'article sur la DI). Si ta registry est un simple objet créé au démarrage et injecte par le constructeur, ca va. Si c'est un singleton global qu'on importe partout, ca sent mauvais.
Résumé
- En TypeScript, une Strategy est souvent un type de fonction
- Utilise des classes quand la stratégie a besoin de configuration ou de plusieurs méthodes
- Passe de if/else a Strategy quand tu as 3+ variantes ou que le nombre va grandir
- Strategy et Adapter sont souvent utilises ensemble
Article précédent : 02 - Repository
Article suivant : 04 - Observer et EventEmitter