Design Pattern
Comportementaux

Strategy

Encapsuler des algorithmes interchangeables

Problème

Plusieurs variantes d'un algorithme doivent être utilisables de façon interchangeable. Plutôt qu'un switch qui grandit à chaque nouvelle variante, on encapsule chaque algorithme dans un objet.

Exemple : stratégies de tri

interface SortStrategy<T> {
  sort(items: T[], compareFn: (a: T, b: T) => number): T[];
}

class QuickSort<T> implements SortStrategy<T> {
  sort(items: T[], compareFn: (a: T, b: T) => number): T[] {
    return [...items].sort(compareFn); // Array.prototype.sort utilise quicksort
  }
}

class StableSort<T> implements SortStrategy<T> {
  sort(items: T[], compareFn: (a: T, b: T) => number): T[] {
    return [...items]
      .map((item, index) => ({ item, index }))
      .sort((a, b) => compareFn(a.item, b.item) || a.index - b.index)
      .map(({ item }) => item);
  }
}

class ProductList {
  constructor(
    private items: Product[],
    private sortStrategy: SortStrategy<Product>
  ) {}

  setSortStrategy(strategy: SortStrategy<Product>) {
    this.sortStrategy = strategy;
  }

  getSorted(field: keyof Product): Product[] {
    return this.sortStrategy.sort(
      this.items,
      (a, b) => String(a[field]).localeCompare(String(b[field]))
    );
  }
}

Stratégies de validation

Pattern très courant en front-end :

type ValidationResult = { valid: true } | { valid: false; error: string };
type Validator = (value: string) => ValidationResult;

const required: Validator = (value) =>
  value.trim() ? { valid: true } : { valid: false, error: 'Ce champ est requis' };

const email: Validator = (value) =>
  /\S+@\S+\.\S+/.test(value)
    ? { valid: true }
    : { valid: false, error: 'Email invalide' };

const minLength = (min: number): Validator => (value) =>
  value.length >= min
    ? { valid: true }
    : { valid: false, error: `Minimum ${min} caractères` };

const maxLength = (max: number): Validator => (value) =>
  value.length <= max
    ? { valid: true }
    : { valid: false, error: `Maximum ${max} caractères` };

// Composition de stratégies
function validate(value: string, validators: Validator[]): string | null {
  for (const validator of validators) {
    const result = validator(value);
    if (!result.valid) return result.error;
  }
  return null;
}

// Chaque champ a ses propres stratégies de validation
const passwordValidators = [required, minLength(8), maxLength(100)];
const emailValidators = [required, email];

const passwordError = validate(password, passwordValidators);
const emailError = validate(emailValue, emailValidators);

Strategy en React : hooks

// Stratégies de fetching interchangeables
interface FetchStrategy<T> {
  fetch(params: unknown): Promise<T>;
}

class RestFetchStrategy<T> implements FetchStrategy<T> {
  constructor(private url: string) {}
  async fetch(params: Record<string, string>): Promise<T> {
    const query = new URLSearchParams(params).toString();
    return fetch(`${this.url}?${query}`).then(r => r.json());
  }
}

class GraphQLFetchStrategy<T> implements FetchStrategy<T> {
  constructor(private query: string) {}
  async fetch(variables: unknown): Promise<T> {
    return fetch('/graphql', {
      method: 'POST',
      body: JSON.stringify({ query: this.query, variables }),
    }).then(r => r.json()).then(r => r.data);
  }
}

function useDataFetch<T>(strategy: FetchStrategy<T>, params: unknown) {
  return useQuery({
    queryKey: ['data', params],
    queryFn: () => strategy.fetch(params),
  });
}

// En prod avec une REST API
const productsStrategy = new RestFetchStrategy<Product[]>('/api/products');
const { data } = useDataFetch(productsStrategy, { category: 'electronics' });

Stratégies de paiement

interface PaymentStrategy {
  pay(amount: number): Promise<{ success: boolean; transactionId: string }>;
}

class StripePayment implements PaymentStrategy {
  constructor(private paymentMethodId: string) {}
  async pay(amount: number) {
    const intent = await stripe.confirmCardPayment(clientSecret, {
      payment_method: this.paymentMethodId,
    });
    return { success: !!intent.paymentIntent, transactionId: intent.paymentIntent?.id ?? '' };
  }
}

class PayPalPayment implements PaymentStrategy {
  async pay(amount: number) {
    const order = await paypal.createOrder({ amount });
    return { success: order.status === 'COMPLETED', transactionId: order.id };
  }
}

// Le processus de checkout ne connaît pas la stratégie de paiement
async function processOrder(order: Order, paymentStrategy: PaymentStrategy) {
  const result = await paymentStrategy.pay(order.total);
  if (!result.success) throw new Error('Payment failed');
  await orderService.confirm(order.id, result.transactionId);
}

Exercice : Strategy dans le Pokémon Builder

Contexte

Dans App.tsx, le filtrage des Pokémon est fait inline dans le rendu :

const filtered = pokemons.filter((p) => {
  const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
  const matchesType = selectedType === "all" || p.types.includes(selectedType);
  return matchesSearch && matchesType;
});

Ajouter un tri (par stats, par numéro de Pokédex, par nom) implique de modifier ce bloc directement. Il n'y a aucune séparation entre le "comment filtrer" et le "quand filtrer".

Objectif

Extraire les algorithmes de filtrage et de tri dans des stratégies interchangeables.

Instructions

  1. Crée le fichier src/strategy/filterStrategies.ts.

  2. Définis une interface FilterStrategy avec une méthode apply(pokemons: Pokemon[]): Pokemon[].

  3. Implémente trois stratégies :

    • ByNameStrategy(query: string), filtre par nom
    • ByTypeStrategy(type: string), filtre par type ("all" retourne tout)
    • BySortStrategy(criterion: "name" | "hp" | "attack" | "speed"), trie par critère
  4. Dans App.tsx, ajoute un état sortBy et compose les stratégies pour produire la liste filtrée et triée.

  5. Ajoute un <select> dans FilterBar pour choisir le critère de tri.

Résultat attendu

Ajouter un nouveau critère de tri ou de filtrage ne nécessite d'écrire qu'une nouvelle classe, sans toucher à App.tsx.