Cours JavaScript et TypeScript · Module 9: Design patterns et architecture

La programmation fonctionnelle en JavaScript/TypeScript

25 min de lecture
Dans cette leçon6

Imagine un laboratoire où le même échantillon d'ADN donne un résultat différent selon l'heure de la journée, parce que la fonction d'analyse jette un œil à une variable globale. La programmation fonctionnelle soigne ce mal avec des fonctions pures, l'immutabilité et la composition, qui rendent le code prévisible, testable et plus facile à maintenir.

Les fonctions pures (Pure Functions)

Une fonction pure remplit deux conditions :

  1. Déterminisme - pour les mêmes arguments, elle renvoie toujours le même résultat
  2. Absence d'effets de bord - elle ne modifie pas l'état extérieur et n'effectue aucune opération d'entrée/sortie (I/O)

Exemples de fonctions impures

Chacune de ces fonctions enfreint l'une des deux conditions. Laquelle ?

1// Fonction impure - dépend d'un état extérieur
2let tax = 0.23;
3
4function calculatePrice(basePrice) {
5  return basePrice * (1 + tax); // Dépend d'une variable extérieure
6}
7
8// Fonction impure - modifie un état extérieur
9let counter = 0;
10
11function incrementCounter() {
12  counter++; // Effet de bord - modification d'une variable globale
13  return counter;
14}
15
16// Fonction impure - effet de bord (I/O)
17function greetUser(name) {
18  console.log(`Bonjour, ${name} !`); // Effet de bord - sortie dans la console
19  return name;
20}
21
22// Fonction impure - non-déterminisme
23function getCurrentTimestamp() {
24  return Date.now(); // Un résultat différent à chaque appel
25}
26
27// Fonction impure - modifie son argument
28function addItem(array, item) {
29  array.push(item); // Mute l'argument reçu
30  return array;
31}

calculatePrice et getCurrentTimestamp enfreignent le déterminisme, et les autres ont des effets de bord : elles modifient une variable globale, écrivent dans la console ou mutent le tableau reçu.

Exemples de fonctions pures

Les équivalents purs reçoivent tout en arguments et renvoient de nouvelles valeurs :

1// Fonction pure - déterminisme et aucun effet de bord
2function calculatePrice(basePrice: number, taxRate: number): number {
3  return basePrice * (1 + taxRate);
4}
5
6// Fonction pure - opération mathématique
7function add(a: number, b: number): number {
8  return a + b;
9}
10
11// Fonction pure - transformation de données
12function formatFullName(firstName: string, lastName: string): string {
13  return `${firstName} ${lastName}`;
14}
15
16// Fonction pure - travail sur les tableaux (sans mutation)
17function addItem<T>(array: T[], item: T): T[] {
18  return [...array, item]; // Renvoie un nouveau tableau
19}
20
21// Fonction pure - filtrage
22function filterEvenNumbers(numbers: number[]): number[] {
23  return numbers.filter(num => num % 2 === 0);
24}
25
26// Fonction pure - transformation d'objet
27interface User {
28  id: number;
29  name: string;
30  age: number;
31}
32
33function updateUserAge(user: User, newAge: number): User {
34  return {
35    ...user, // Spread operator - copie des propriétés existantes
36    age: newAge // Remplacement de l'âge
37  };
38}
39
40// Fonction pure - validation
41function isValidEmail(email: string): boolean {
42  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
43  return emailRegex.test(email);
44}
45
46// Utilisation des fonctions pures
47const products = [
48  { name: 'Laptop', basePrice: 2000 },
49  { name: 'Mouse', basePrice: 50 },
50  { name: 'Keyboard', basePrice: 150 }
51];
52
53const TAX_RATE = 0.23;
54
55const productsWithTax = products.map(product => ({
56  ...product,
57  finalPrice: calculatePrice(product.basePrice, TAX_RATE)
58}));
59
60console.log(productsWithTax);

La taxe devient un paramètre, et addItem comme updateUserAge renvoient des copies, donc les originaux restent intacts.

Les avantages des fonctions pures

Le déterminisme apporte deux superpouvoirs : des tests sans avoir à préparer le monde entier, et la mémoïsation, c'est-à-dire la mise en mémoire des résultats :

1// Facile à tester - aucune dépendance extérieure
2function calculateDiscount(price: number, discountPercent: number): number {
3  return price * (discountPercent / 100);
4}
5
6// Le test unitaire est simple et fiable
7console.assert(calculateDiscount(100, 10) === 10);
8console.assert(calculateDiscount(200, 25) === 50);
9
10// Mémoïsation - une optimisation rendue possible par le déterminisme
11function memoize<TArgs extends any[], TReturn>(
12  fn: (...args: TArgs) => TReturn
13): (...args: TArgs) => TReturn {
14  const cache = new Map<string, TReturn>();
15
16  return (...args: TArgs): TReturn => {
17    const key = JSON.stringify(args);
18
19    if (cache.has(key)) {
20      console.log('Cache hit!');
21      return cache.get(key)!;
22    }
23
24    console.log('Computing...');
25    const result = fn(...args);
26    cache.set(key, result);
27    return result;
28  };
29}
30
31// Fonction coûteuse en calcul
32function fibonacci(n: number): number {
33  if (n <= 1) return n;
34  return fibonacci(n - 1) + fibonacci(n - 2);
35}
36
37// Version mémoïsée
38const memoizedFibonacci = memoize(fibonacci);
39
40console.log(memoizedFibonacci(10)); // Computing... 55
41console.log(memoizedFibonacci(10)); // Cache hit! 55

memoize est une fonction d'ordre supérieur (higher-order function) : elle accepte une autre fonction en argument ou renvoie une fonction, et ici elle fait les deux. C'est aussi le cas de map, de filter ou de subscribe dans le pattern Observer. Les appels récursifs de fibonacci contournent toutefois le cache, donc le gain n'apparaît qu'au deuxième appel.

L'immutabilité (Immutability)

L'immutabilité signifie que les objets et les structures de données ne sont pas modifiés après leur création. À la place, on crée de nouvelles versions qui contiennent les changements, comme dans des archives où tu n'écrases pas l'ancien échantillon, mais où tu en ranges un nouveau à côté.

Travailler avec les primitives

Avec les primitives, tu l'obtiens gratuitement, car les chaînes et les nombres sont immuables par nature en JavaScript :

1// Les primitives sont immuables par nature en JavaScript
2let name = 'Jan';
3let newName = name.toUpperCase(); // Crée une nouvelle chaîne
4console.log(name);    // 'Jan' - l'original n'a pas changé
5console.log(newName); // 'JAN' - nouvelle valeur

toUpperCase() ne modifie pas la chaîne name, elle en renvoie une nouvelle.

Travailler avec les tableaux

Avec les tableaux, reste prudent, car certaines méthodes mutent l'original. Compare les deux groupes :

1// Opérations mutables (elles modifient le tableau original)
2const originalArray = [1, 2, 3];
3originalArray.push(4);        // Mutation
4originalArray.pop();          // Mutation
5originalArray.splice(1, 1);   // Mutation
6originalArray.sort();         // Mutation
7
8// Opérations immuables (elles créent de nouveaux tableaux)
9const numbers = [1, 2, 3];
10
11// Ajout d'éléments
12const withNewElement = [...numbers, 4];
13const withElementAtStart = [0, ...numbers];
14const withElementInMiddle = [...numbers.slice(0, 2), 2.5, ...numbers.slice(2)];
15
16// Suppression d'éléments
17const withoutFirst = numbers.slice(1);
18const withoutLast = numbers.slice(0, -1);
19const withoutMiddle = numbers.filter((_, index) => index !== 1);
20
21// Modification d'éléments
22const doubled = numbers.map(x => x * 2);
23const incremented = numbers.map(x => x + 1);
24
25// Tri
26const sorted = [...numbers].sort();
27const reverseSorted = [...numbers].sort((a, b) => b - a);
28
29console.log('Tableau original :', numbers);  // [1, 2, 3]
30console.log('Doublé :', doubled);            // [2, 4, 6]
31console.log('Trié :', sorted);               // [1, 2, 3]
32
33// Opérations immuables avancées
34function updateArrayElement<T>(
35  array: T[],
36  index: number,
37  newValue: T
38): T[] {
39  return array.map((item, i) => i === index ? newValue : item);
40}
41
42function moveArrayElement<T>(
43  array: T[],
44  fromIndex: number,
45  toIndex: number
46): T[] {
47  const result = [...array];
48  const [removed] = result.splice(fromIndex, 1);
49  result.splice(toIndex, 0, removed);
50  return result;
51}
52
53const fruits = ['apple', 'banana', 'cherry'];
54const updatedFruits = updateArrayElement(fruits, 1, 'blueberry');
55const movedFruits = moveArrayElement(fruits, 0, 2);
56
57console.log('Original :', fruits); // ['apple', 'banana', 'cherry']
58console.log('Mis à jour :', updatedFruits);    // ['apple', 'blueberry', 'cherry']
59console.log('Déplacé :', movedFruits);         // ['banana', 'cherry', 'apple']

La règle : push, pop, splice et sort modifient le tableau, tandis que le spread, slice, map et filter en renvoient un nouveau. Attention : sans fonction de comparaison, sort() trie comme des chaînes ([10, 9, 1].sort() donne [1, 10, 9]), et depuis ES2023 tu disposes des versions non mutantes toSorted(), toReversed(), toSpliced() et with().

Travailler avec les objets

On copie les objets avec l'opérateur spread. En cas d'imbrication, on copie chaque niveau que l'on modifie :

1// Opérations mutables sur les objets
2const user = { id: 1, name: 'Jan', age: 30 };
3user.age = 31;           // Mutation
4user.email = 'jan@example.com'; // Mutation
5delete user.name;        // Mutation
6
7// Opérations immuables sur les objets
8const originalUser = { id: 1, name: 'Jan', age: 30 };
9
10// Mise à jour de propriétés
11const olderUser = { ...originalUser, age: 31 };
12
13// Ajout de propriétés
14const userWithEmail = { ...originalUser, email: 'jan@example.com' };
15
16// Suppression de propriétés
17const { name, ...userWithoutName } = originalUser;
18
19// Mises à jour imbriquées
20interface Address {
21  street: string;
22  city: string;
23  country: string;
24}
25
26interface UserWithAddress {
27  id: number;
28  name: string;
29  address: Address;
30}
31
32const userWithAddress: UserWithAddress = {
33  id: 1,
34  name: 'Jan',
35  address: {
36    street: 'Main St',
37    city: 'Warsaw',
38    country: 'Poland'
39  }
40};
41
42// Mise à jour imbriquée mutable
43userWithAddress.address.city = 'Krakow'; // Mutation
44
45// Mise à jour imbriquée immuable
46const userInKrakow = {
47  ...userWithAddress,
48  address: {
49    ...userWithAddress.address,
50    city: 'Krakow'
51  }
52};
53
54// Fonctions utilitaires pour les opérations immuables
55function updateProperty<T, K extends keyof T>(
56  obj: T,
57  key: K,
58  value: T[K]
59): T {
60  return { ...obj, [key]: value };
61}
62
63function updateNestedProperty<T>(
64  obj: T,
65  path: string[],
66  value: any
67): T {
68  if (path.length === 1) {
69    return { ...obj, [path[0]]: value };
70  }
71
72  const [firstKey, ...restPath] = path;
73  return {
74    ...obj,
75    [firstKey]: updateNestedProperty(obj[firstKey as keyof T], restPath, value)
76  };
77}
78
79// Utilisation des fonctions utilitaires
80const updatedUser = updateProperty(originalUser, 'age', 35);
81const userInGdansk = updateNestedProperty(
82  userWithAddress,
83  ['address', 'city'],
84  'Gdansk'
85);
86
87console.log('Original :', originalUser); // { id: 1, name: 'Jan', age: 30 }
88console.log('Mis à jour :', updatedUser); // { id: 1, name: 'Jan', age: 35 }

Le spread fait une copie superficielle, c'est pourquoi on déballe address séparément. En TypeScript (mode strict), les lignes avec user.email et delete user.name ne compileront pas, car le type n'a pas de champ email, et delete exige un champ optionnel.

Des bibliothèques pour l'immutabilité

Copier à la main des structures profondes est épuisant, c'est pour cela qu'Immer existe : tu écris du code qui ressemble à la mutation d'un brouillon (draft), et la bibliothèque renvoie un nouvel état :

1// Immer - une bibliothèque qui facilite le travail avec les structures immuables
2// npm install immer
3
4import { produce } from 'immer';
5
6interface Todo {
7  id: number;
8  text: string;
9  completed: boolean;
10}
11
12const todos: Todo[] = [
13  { id: 1, text: 'Apprendre JavaScript', completed: false },
14  { id: 2, text: 'Apprendre TypeScript', completed: true },
15  { id: 3, text: 'Apprendre React', completed: false }
16];
17
18// Avec Immer - cela ressemble à une mutation, mais crée un nouvel état
19const updatedTodos = produce(todos, draft => {
20  const todo = draft.find(t => t.id === 1);
21  if (todo) {
22    todo.completed = true;
23  }
24});
25
26const todosWithNewItem = produce(todos, draft => {
27  draft.push({
28    id: 4,
29    text: 'Apprendre la programmation fonctionnelle',
30    completed: false
31  });
32});
33
34console.log('Todos originaux :', todos);
35console.log('Todos mis à jour :', updatedTodos);
36console.log('Todos avec un nouvel élément :', todosWithNewItem);
37
38// Exemple avancé avec des structures imbriquées
39interface Project {
40  id: number;
41  name: string;
42  todos: Todo[];
43  members: string[];
44}
45
46const project: Project = {
47  id: 1,
48  name: 'Projet A',
49  todos: todos,
50  members: ['Jan', 'Anna']
51};
52
53const updatedProject = produce(project, draft => {
54  // Ajout d'un nouveau membre
55  draft.members.push('Piotr');
56
57  // Marquer tous les todos comme terminés
58  draft.todos.forEach(todo => {
59    todo.completed = true;
60  });
61
62  // Changement du nom du projet
63  draft.name = 'Projet A - Terminé';
64});
65
66console.log('Projet original :', project);
67console.log('Projet mis à jour :', updatedProject);

Les originaux restent intacts, et les fragments inchangés sont partagés. Pour un état profondément imbriqué, je te recommande Immer.

Currying (curryfication)

Le currying est une technique qui transforme une fonction à plusieurs arguments en une série de fonctions qui prennent chacune un seul argument.

Le currying de base

Regarde la même fonction de multiplication en version classique et en version curryfiée :

1// Fonction à plusieurs arguments
2function multiply(a: number, b: number, c: number): number {
3  return a * b * c;
4}
5
6console.log(multiply(2, 3, 4)); // 24
7
8// Version curryfiée
9function curriedMultiply(a: number) {
10  return function(b: number) {
11    return function(c: number) {
12      return a * b * c;
13    };
14  };
15}
16
17console.log(curriedMultiply(2)(3)(4)); // 24
18
19// Version curryfiée avec des fonctions fléchées
20const curriedMultiplyArrow = (a: number) => (b: number) => (c: number) => a * b * c;
21
22console.log(curriedMultiplyArrow(2)(3)(4)); // 24
23
24// Application partielle (partial application)
25const multiplyBy2 = curriedMultiply(2);
26const multiplyBy2And3 = multiplyBy2(3);
27
28console.log(multiplyBy2And3(4)); // 24
29console.log(multiplyBy2And3(5)); // 30

Chaque appel renvoie une nouvelle fonction, jusqu'à l'arrivée du dernier argument. multiplyBy2 est une application partielle (partial application) : elle retient le premier argument grâce à une closure.

Les applications pratiques du currying

La vraie puissance apparaît quand les premiers arguments servent de configuration et que le dernier contient les données :

1// Système de journalisation avec currying
2type LogLevel = 'info' | 'warn' | 'error';
3
4const logger = (level: LogLevel) => (message: string) => (context?: object) => {
5  const timestamp = new Date().toISOString();
6  const logEntry = {
7    level,
8    message,
9    timestamp,
10    context
11  };
12
13  console.log(JSON.stringify(logEntry, null, 2));
14};
15
16// Création de loggers spécialisés
17const infoLogger = logger('info');
18const errorLogger = logger('error');
19const warnLogger = logger('warn');
20
21// Utilisation
22infoLogger("L'application a démarré")();
23errorLogger('Erreur de connexion à la base de données')({ database: 'PostgreSQL' });
24warnLogger('Utilisation élevée de la RAM')({ usage: '85%' });
25
26// Système de validation avec currying
27type Validator<T> = (value: T) => boolean;
28
29const createValidator = <T>(predicate: (value: T) => boolean) =>
30  (errorMessage: string) =>
31  (value: T): { isValid: boolean; error?: string } => {
32    const isValid = predicate(value);
33    return isValid ? { isValid } : { isValid, error: errorMessage };
34  };
35
36// Prédicats de validation
37const isNotEmpty = (value: string) => value.trim().length > 0;
38const isEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
39const isMinLength = (minLength: number) => (value: string) => value.length >= minLength;
40const isNumber = (value: string) => !isNaN(Number(value));
41
42// Création des validateurs
43const requiredValidator = createValidator(isNotEmpty)('Ce champ est obligatoire');
44const emailValidator = createValidator(isEmail)("Format d'e-mail invalide");
45const passwordValidator = createValidator(isMinLength(8))('Le mot de passe doit contenir au moins 8 caractères');
46const ageValidator = createValidator(isNumber)("L'âge doit être un nombre");
47
48// Utilisation des validateurs
49console.log(requiredValidator('Jan'));        // { isValid: true }
50console.log(requiredValidator(''));           // { isValid: false, error: 'Ce champ est obligatoire' }
51console.log(emailValidator('jan@example.com')); // { isValid: true }
52console.log(passwordValidator('123'));         // { isValid: false, error: 'Le mot de passe doit contenir au moins 8 caractères' }
53
54// Système de configuration d'API avec currying
55const apiCall = (baseUrl: string) =>
56  (endpoint: string) =>
57  (method: 'GET' | 'POST' | 'PUT' | 'DELETE') =>
58  (data?: any) => {
59    const config = {
60      url: `${baseUrl}${endpoint}`,
61      method,
62      data
63    };
64
65    console.log('API Call:', config);
66    // Ici aurait lieu le véritable appel à l'API
67    return Promise.resolve({ success: true, config });
68  };
69
70// Configuration pour différents environnements
71const devApi = apiCall('https://dev-api.example.com');
72const prodApi = apiCall('https://api.example.com');
73
74// Fonctions spécialisées pour différents endpoints
75const usersApi = devApi('/users');
76const productsApi = devApi('/products');
77
78// Opérations concrètes
79const getUsers = usersApi('GET');
80const createUser = usersApi('POST');
81const updateUser = usersApi('PUT');
82
83// Utilisation
84getUsers();
85createUser({ name: 'Jan', email: 'jan@example.com' });
86updateUser({ id: 1, name: 'Jan Kowalski' });

Chacune de ces fonctions est une petite fabrique : createValidator produit des validateurs, et apiCall des clients d'API. Le pattern Factory faisait la même chose avec une classe, par exemple DinoFactory et sa méthode create(type) qui renvoie new Dinosaur(type), en séparant la création de l'utilisation.

Le currying automatique

Le helper curry curryfie une fonction automatiquement, en collectant les arguments jusqu'à en avoir autant qu'elle a de paramètres :

1// Fonction utilitaire pour le currying automatique
2function curry<TArgs extends any[], TReturn>(
3  fn: (...args: TArgs) => TReturn
4): (...args: any[]) => any {
5  return function curried(...args: any[]) {
6    if (args.length >= fn.length) {
7      return fn(...args as TArgs);
8    } else {
9      return function(...nextArgs: any[]) {
10        return curried(...args, ...nextArgs);
11      };
12    }
13  };
14}
15
16// Exemple d'utilisation du currying automatique
17function sum(a: number, b: number, c: number, d: number): number {
18  return a + b + c + d;
19}
20
21const curriedSum = curry(sum);
22
23// Différentes façons d'appeler la fonction
24console.log(curriedSum(1, 2, 3, 4));     // 10
25console.log(curriedSum(1)(2)(3)(4));     // 10
26console.log(curriedSum(1, 2)(3, 4));     // 10
27console.log(curriedSum(1)(2, 3)(4));     // 10
28
29// Application partielle
30const add5 = curriedSum(5);
31const add5And10 = add5(10);
32const add5And10And15 = add5And10(15);
33
34console.log(add5And10And15(20)); // 50
35
36// Exemple avec filtrage et tri
37function filterAndSort<T>(
38  predicate: (item: T) => boolean,
39  compareKey: keyof T,
40  array: T[]
41): T[] {
42  return array
43    .filter(predicate)
44    .sort((a, b) => {
45      if (a[compareKey] < b[compareKey]) return -1;
46      if (a[compareKey] > b[compareKey]) return 1;
47      return 0;
48    });
49}
50
51const curriedFilterAndSort = curry(filterAndSort);
52
53interface Product {
54  id: number;
55  name: string;
56  price: number;
57  category: string;
58  inStock: boolean;
59}
60
61const products: Product[] = [
62  { id: 1, name: 'Laptop', price: 2000, category: 'Electronics', inStock: true },
63  { id: 2, name: 'Mouse', price: 50, category: 'Electronics', inStock: false },
64  { id: 3, name: 'Book', price: 25, category: 'Books', inStock: true },
65  { id: 4, name: 'Keyboard', price: 150, category: 'Electronics', inStock: true }
66];
67
68// Fonctions spécialisées
69const filterInStockSortByPrice = curriedFilterAndSort(
70  (product: Product) => product.inStock
71)('price');
72
73const filterElectronicsSortByName = curriedFilterAndSort(
74  (product: Product) => product.category === 'Electronics'
75)('name');
76
77console.log('Produits disponibles triés par prix :', filterInStockSortByPrice(products));
78console.log('Électronique triée par nom :', filterElectronicsSortByName(products));

curry s'appuie sur fn.length, qui ne compte ni le paramètre rest, ni les paramètres situés à partir du premier qui a une valeur par défaut.

La composition de fonctions (Function Composition)

La composition de fonctions consiste à assembler des fonctions simples en fonctions plus complexes, en passant le résultat de l'une comme argument à la suivante, comme les étapes d'une chaîne de laboratoire.

La composition de base

Au lieu d'imbriquer les appels, on construit un pipeline. compose exécute les fonctions de droite à gauche, et pipe de gauche à droite :

1// Fonctions simples
2const add = (x: number) => x + 1;
3const multiply = (x: number) => x * 2;
4const square = (x: number) => x * x;
5
6// Sans composition - des appels imbriqués
7const result1 = square(multiply(add(5))); // ((5 + 1) * 2)^2 = 144
8
9// Fonction compose - exécute les fonctions de droite à gauche
10function compose<T>(...functions: Array<(arg: T) => T>) {
11  return (value: T): T => {
12    return functions.reduceRight((acc, fn) => fn(acc), value);
13  };
14}
15
16// Fonction pipe - exécute les fonctions de gauche à droite (plus intuitive)
17function pipe<T>(...functions: Array<(arg: T) => T>) {
18  return (value: T): T => {
19    return functions.reduce((acc, fn) => fn(acc), value);
20  };
21}
22
23// Utilisation de compose
24const composedFunction = compose(square, multiply, add);
25console.log(composedFunction(5)); // 144
26
27// Utilisation de pipe (plus lisible)
28const pipedFunction = pipe(add, multiply, square);
29console.log(pipedFunction(5)); // 144
30
31// Exemple pratique - traitement des données utilisateur
32interface User {
33  firstName: string;
34  lastName: string;
35  age: number;
36  email: string;
37}
38
39const users: User[] = [
40  { firstName: 'Jan', lastName: 'Kowalski', age: 25, email: 'jan@example.com' },
41  { firstName: 'Anna', lastName: 'Nowak', age: 30, email: 'anna@example.com' },
42  { firstName: 'Piotr', lastName: 'Wisniewski', age: 22, email: 'piotr@example.com' }
43];
44
45// Fonctions auxiliaires
46const filterAdults = (users: User[]) => users.filter(user => user.age >= 18);
47const sortByAge = (users: User[]) => [...users].sort((a, b) => a.age - b.age);
48const mapToFullNames = (users: User[]) =>
49  users.map(user => `${user.firstName} ${user.lastName}`);
50
51// Composition
52const processUsers = pipe(
53  filterAdults,
54  sortByAge,
55  mapToFullNames
56);
57
58const processedUsers = processUsers(users);
59console.log(processedUsers); // ['Piotr Wisniewski', 'Jan Kowalski', 'Anna Nowak']

Les deux variantes donnent 144, mais pipe se lit dans l'ordre d'exécution, et c'est celle que je te recommande. Le piège : chaque étape doit renvoyer le même type, donc mapToFullNames provoquera une erreur de type en TypeScript, même sans le mode strict.

La composition avancée avec TypeScript

Quand le type change en cours de route, chaque étape a besoin de son propre paramètre de type, comme dans compose3 :

1// Composition typée pour différents types
2function compose2<A, B, C>(f: (b: B) => C, g: (a: A) => B) {
3  return (a: A): C => f(g(a));
4}
5
6function compose3<A, B, C, D>(
7  f: (c: C) => D,
8  g: (b: B) => C,
9  h: (a: A) => B
10) {
11  return (a: A): D => f(g(h(a)));
12}
13
14// Exemple d'utilisation de la composition typée
15const parseNumber = (str: string): number => parseFloat(str);
16const addTax = (price: number): number => price * 1.23;
17const formatCurrency = (amount: number): string => `${amount.toFixed(2)} PLN`;
18
19const processPrice = compose3(formatCurrency, addTax, parseNumber);
20
21console.log(processPrice('100')); // '123.00 PLN'
22
23// Système de validation par composition
24type ValidationResult = {
25  isValid: boolean;
26  errors: string[];
27};
28
29type Validator<T> = (value: T) => ValidationResult;
30
31const combineValidators = <T>(...validators: Validator<T>[]): Validator<T> => {
32  return (value: T): ValidationResult => {
33    const results = validators.map(validator => validator(value));
34
35    return {
36      isValid: results.every(result => result.isValid),
37      errors: results.flatMap(result => result.errors)
38    };
39  };
40};
41
42// Validateurs individuels
43const isRequired: Validator<string> = (value: string) => ({
44  isValid: value.trim().length > 0,
45  errors: value.trim().length === 0 ? ['Ce champ est obligatoire'] : []
46});
47
48const isEmail: Validator<string> = (value: string) => {
49  const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
50  return {
51    isValid,
52    errors: isValid ? [] : ["Format d'e-mail invalide"]
53  };
54};
55
56const minLength = (min: number): Validator<string> => (value: string) => {
57  const isValid = value.length >= min;
58  return {
59    isValid,
60    errors: isValid ? [] : [`Minimum ${min} caractères requis`]
61  };
62};
63
64// Composition des validateurs
65const emailValidator = combineValidators(isRequired, isEmail);
66const passwordValidator = combineValidators(isRequired, minLength(8));
67
68console.log(emailValidator('jan@example.com')); // { isValid: true, errors: [] }
69console.log(emailValidator(''));                // { isValid: false, errors: ['Ce champ est obligatoire', "Format d'e-mail invalide"] }
70console.log(passwordValidator('123'));          // { isValid: false, errors: ['Minimum 8 caractères requis'] }
71
72// Traitement de données par composition
73interface RawProduct {
74  name: string;
75  price: string;
76  description: string;
77  tags: string;
78}
79
80interface ProcessedProduct {
81  name: string;
82  price: number;
83  description: string;
84  tags: string[];
85  slug: string;
86}
87
88// Fonctions de transformation
89const parsePrice = (product: RawProduct): ProcessedProduct => ({
90  ...product,
91  price: parseFloat(product.price),
92  tags: product.tags.split(','), // fragments bruts, parseTags les nettoiera
93  slug: ''
94});
95
96const parseTags = (product: ProcessedProduct): ProcessedProduct => ({
97  ...product,
98  tags: product.tags.map(tag => tag.trim()).filter(tag => tag.length > 0)
99});
100
101const generateSlug = (product: ProcessedProduct): ProcessedProduct => ({
102  ...product,
103  slug: product.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
104});
105
106const validateProduct = (product: ProcessedProduct): ProcessedProduct => {
107  if (product.price <= 0) {
108    throw new Error('Le prix doit être supérieur à 0');
109  }
110  if (!product.name.trim()) {
111    throw new Error('Le nom du produit est obligatoire');
112  }
113  return product;
114};
115
116// Composition du traitement - parsePrice change le type, donc on l'appelle avant pipe
117const processProduct = (raw: RawProduct): ProcessedProduct => pipe(
118  parseTags,
119  generateSlug,
120  validateProduct
121)(parsePrice(raw));
122
123const rawProduct: RawProduct = {
124  name: 'Super Laptop',
125  price: '2999.99',
126  description: 'Le meilleur laptop du marché',
127  tags: 'laptop, ordinateur, électronique'
128};
129
130try {
131  const processed = processProduct(rawProduct);
132  console.log('Produit traité :', processed);
133} catch (error) {
134  console.error('Erreur de traitement :', (error as Error).message);
135}

combineValidators rassemble les erreurs de tous les validateurs, et l'étape qui change le type, parsePrice, est appelée avant pipe.

La composition asynchrone

Le pipeline fonctionne aussi avec les promesses (Promise). Chaque étape attend le résultat de la précédente grâce à then :

1// Composition de fonctions asynchrones
2const composeAsync = <T>(...functions: Array<(arg: T) => Promise<T>>) => {
3  return (value: T): Promise<T> => {
4    return functions.reduceRight<Promise<T>>(
5      (acc, fn) => acc.then(fn),
6      Promise.resolve(value)
7    );
8  };
9};
10
11const pipeAsync = <T>(...functions: Array<(arg: T) => Promise<T>>) => {
12  return (value: T): Promise<T> => {
13    return functions.reduce<Promise<T>>(
14      (acc, fn) => acc.then(fn),
15      Promise.resolve(value)
16    );
17  };
18};
19
20// Exemple d'utilisation avec une API
21interface ApiUser {
22  id: number;
23  name: string;
24  email: string;
25}
26
27interface EnrichedUser extends ApiUser {
28  avatar: string;
29  lastLogin: Date;
30  preferences: any;
31}
32
33// Fonctions asynchrones
34const fetchUser = async (userId: number): Promise<ApiUser> => {
35  console.log(`Fetching user ${userId}`);
36  // Simulation d'un appel API
37  return new Promise(resolve => {
38    setTimeout(() => {
39      resolve({
40        id: userId,
41        name: 'Jan Kowalski',
42        email: 'jan@example.com'
43      });
44    }, 100);
45  });
46};
47
48const enrichWithAvatar = async (user: ApiUser): Promise<EnrichedUser> => {
49  console.log('Enriching with avatar');
50  return new Promise(resolve => {
51    setTimeout(() => {
52      resolve({
53        ...user,
54        avatar: `https://avatars.example.com/${user.id}`,
55        lastLogin: new Date(),
56        preferences: {}
57      });
58    }, 50);
59  });
60};
61
62const enrichWithPreferences = async (user: EnrichedUser): Promise<EnrichedUser> => {
63  console.log('Enriching with preferences');
64  return new Promise(resolve => {
65    setTimeout(() => {
66      resolve({
67        ...user,
68        preferences: {
69          theme: 'dark',
70          language: 'pl',
71          notifications: true
72        }
73      });
74    }, 50);
75  });
76};
77
78// Composition asynchrone - enrichWithAvatar change le type (ApiUser -> EnrichedUser),
79// donc on l'appelle avant le pipeline
80const enrichUser = (user: ApiUser): Promise<EnrichedUser> =>
81  enrichWithAvatar(user).then(pipeAsync(enrichWithPreferences));
82
83// Utilisation
84async function main() {
85  try {
86    const user = await fetchUser(1);
87    const enrichedUser = await enrichUser(user);
88    console.log('Utilisateur enrichi :', enrichedUser);
89  } catch (error) {
90    console.error('Erreur :', error);
91  }
92}
93
94main();

reduce tresse les étapes en une seule chaîne de promesses, en conservant leur ordre.

Exemple pratique - un système e-commerce en programmation fonctionnelle

Pour finir, tout est réuni : des filtres purs, un panier immuable, du currying et pipe :

1// Exemple complet d'application de la programmation fonctionnelle
2
3// Types de données
4interface Product {
5  id: string;
6  name: string;
7  price: number;
8  category: string;
9  tags: string[];
10  inStock: boolean;
11  rating: number;
12}
13
14interface CartItem {
15  product: Product;
16  quantity: number;
17}
18
19interface Cart {
20  items: CartItem[];
21  discountCode?: string;
22}
23
24interface Order {
25  id: string;
26  items: CartItem[];
27  subtotal: number;
28  discount: number;
29  tax: number;
30  total: number;
31  timestamp: Date;
32}
33
34// Fonctions pures pour les produits
35const filterByCategory = (category: string) => (products: Product[]) =>
36  products.filter(product => product.category === category);
37
38const filterByPriceRange = (min: number, max: number) => (products: Product[]) =>
39  products.filter(product => product.price >= min && product.price <= max);
40
41const filterInStock = (products: Product[]) =>
42  products.filter(product => product.inStock);
43
44const sortByPrice = (ascending: boolean = true) => (products: Product[]) =>
45  [...products].sort((a, b) => ascending ? a.price - b.price : b.price - a.price);
46
47const sortByRating = (products: Product[]) =>
48  [...products].sort((a, b) => b.rating - a.rating);
49
50// Fonctions pour le panier
51const addToCart = (product: Product, quantity: number) => (cart: Cart): Cart => {
52  const existingItemIndex = cart.items.findIndex(item => item.product.id === product.id);
53
54  if (existingItemIndex >= 0) {
55    return {
56      ...cart,
57      items: cart.items.map((item, index) =>
58        index === existingItemIndex
59          ? { ...item, quantity: item.quantity + quantity }
60          : item
61      )
62    };
63  }
64
65  return {
66    ...cart,
67    items: [...cart.items, { product, quantity }]
68  };
69};
70
71const removeFromCart = (productId: string) => (cart: Cart): Cart => ({
72  ...cart,
73  items: cart.items.filter(item => item.product.id !== productId)
74});
75
76const updateQuantity = (productId: string, newQuantity: number) => (cart: Cart): Cart => {
77  if (newQuantity <= 0) {
78    return removeFromCart(productId)(cart);
79  }
80
81  return {
82    ...cart,
83    items: cart.items.map(item =>
84      item.product.id === productId
85        ? { ...item, quantity: newQuantity }
86        : item
87    )
88  };
89};
90
91const applyDiscountCode = (discountCode: string) => (cart: Cart): Cart => ({
92  ...cart,
93  discountCode
94});
95
96// Fonctions de calcul des prix
97const calculateItemTotal = (item: CartItem): number =>
98  item.product.price * item.quantity;
99
100const calculateSubtotal = (items: CartItem[]): number =>
101  items.reduce((total, item) => total + calculateItemTotal(item), 0);
102
103const calculateDiscount = (subtotal: number, discountCode?: string): number => {
104  const discountRates: Record<string, number> = {
105    'SAVE10': 0.1,
106    'SAVE20': 0.2,
107    'STUDENT': 0.15
108  };
109
110  if (!discountCode || !discountRates[discountCode]) {
111    return 0;
112  }
113
114  return subtotal * discountRates[discountCode];
115};
116
117const calculateTax = (amount: number, taxRate: number = 0.23): number =>
118  amount * taxRate;
119
120const calculateTotal = (subtotal: number, discount: number, tax: number): number =>
121  subtotal - discount + tax;
122
123// Composition pour le traitement de la commande
124const processOrder = (cart: Cart): Order => {
125  const subtotal = calculateSubtotal(cart.items);
126  const discount = calculateDiscount(subtotal, cart.discountCode);
127  const taxableAmount = subtotal - discount;
128  const tax = calculateTax(taxableAmount);
129  const total = calculateTotal(subtotal, discount, tax);
130
131  return {
132    id: 'order_' + Math.random().toString(36).slice(2, 11),
133    items: cart.items,
134    subtotal,
135    discount,
136    tax,
137    total,
138    timestamp: new Date()
139  };
140};
141
142// Fonctions de recherche par composition
143const searchProducts = (
144  query: string,
145  category?: string,
146  priceRange?: { min: number; max: number },
147  sortBy: 'price' | 'rating' = 'rating'
148) => (products: Product[]): Product[] => {
149  const searchByName = (products: Product[]) =>
150    products.filter(product =>
151      product.name.toLowerCase().includes(query.toLowerCase()) ||
152      product.tags.some(tag => tag.toLowerCase().includes(query.toLowerCase()))
153    );
154
155  const filters = [
156    searchByName,
157    filterInStock
158  ];
159
160  if (category) {
161    filters.push(filterByCategory(category));
162  }
163
164  if (priceRange) {
165    filters.push(filterByPriceRange(priceRange.min, priceRange.max));
166  }
167
168  const sorter = sortBy === 'price' ? sortByPrice() : sortByRating;
169
170  return pipe(...filters, sorter)(products);
171};
172
173// Exemple d'utilisation
174const products: Product[] = [
175  {
176    id: '1',
177    name: 'Laptop Gaming',
178    price: 3500,
179    category: 'electronics',
180    tags: ['gaming', 'laptop', 'high-performance'],
181    inStock: true,
182    rating: 4.8
183  },
184  {
185    id: '2',
186    name: 'Souris gaming',
187    price: 150,
188    category: 'electronics',
189    tags: ['gaming', 'mouse', 'rgb'],
190    inStock: true,
191    rating: 4.5
192  },
193  {
194    id: '3',
195    name: 'Livre JavaScript',
196    price: 89,
197    category: 'books',
198    tags: ['programming', 'javascript', 'learning'],
199    inStock: false,
200    rating: 4.9
201  }
202];
203
204// Création du panier de façon fonctionnelle
205const emptyCart: Cart = { items: [] };
206
207const myCart = pipe(
208  addToCart(products[0], 1),
209  addToCart(products[1], 2),
210  applyDiscountCode('SAVE10')
211)(emptyCart);
212
213console.log('Panier :', myCart);
214
215// Traitement de la commande
216const order = processOrder(myCart);
217console.log('Commande :', order);
218
219// Recherche de produits
220const searchGaming = searchProducts('gaming', 'electronics', undefined, 'price');
221const gamingProducts = searchGaming(products);
222console.log('Produits gaming :', gamingProducts);
223
224// Application partielle : le produit est fixé, la quantité viendra plus tard
225const addLaptopToCart = (quantity: number) => addToCart(products[0], quantity);
226const addMouseToCart = (quantity: number) => addToCart(products[1], quantity);
227
228const cartWithLaptop = addLaptopToCart(1)(emptyCart);
229const cartWithBoth = addMouseToCart(2)(cartWithLaptop);
230
231console.log('Panier avec le laptop et la souris :', cartWithBoth);

Le panier n'est jamais modifié : chaque opération renvoie une nouvelle version.

Récapitulatif de la programmation fonctionnelle

  1. Fonctions pures - prévisibilité et facilité de test
  2. Immutabilité - sécurité et absence d'effets de bord
  3. Currying - flexibilité et réutilisation
  4. Composition - construire une logique complexe à partir d'éléments simples

Tu y gagnes un code prévisible, des tests unitaires faciles, de meilleures conditions pour le traitement parallèle et moins de bugs dus à la mutation de l'état. Le style fonctionnel brille dans les transformations de données, les calculs, les systèmes qui exigent de la fiabilité et le code testé souvent. Dans l'éditeur ci-dessous, tu construiras un registre de dinosaures, et dans la leçon suivante, les fonctions pures reviendront sous forme de reducers.

« Une fonction pure, c'est un laboratoire aux portes closes : les arguments entrent, le résultat sort », dit le Dr Rex.

Code de cette leçon : index.js
1// Pattern Factory - registre des dinosaures
2// Exercice : implémente une factory et un registre des dinosaures
3
4class Dinosaur {
5  constructor(name, species, diet) {
6    this.name = name;
7    this.species = species;
8    this.diet = diet;
9    this.health = 100;
10  }
11
12  getInfo() {
13    return this.name + " (" + this.species + ") - " + this.diet;
14  }
15}
16
17class DinosaurRegistry {
18  constructor() {
19    this.dinosaurs = [];
20  }
21
22  // Implémente les méthodes :
23  // create(name, species, diet) - crée et enregistre un dinosaure
24  // findBySpecies(species) - recherche par espèce
25  // getAll() - retourne tous les éléments
26
27  create(name, species, diet) {
28    // Ton code ici
29  }
30
31  findBySpecies(species) {
32    // Ton code ici
33  }
34}
35
36const registry = new DinosaurRegistry();
37console.log("Registre des dinosaures prêt");

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quand vaut-il mieux utiliser l'Observer Pattern ?

  2. 2. Qu'est-ce qu'une fonction d'ordre supérieur ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Le fichier index.js contient une classe DinosaurRegistry avec les méthodes register, update et find. Complète les blancs : ___BLANK1___ copie tous les champs de l'ancien enregistrement dans le nouvel objet (comme dans la leçon sur l'immutabilité), ___BLANK2___ est la méthode de tableau qui ne garde que les enregistrements correspondants, et ___BLANK3___ est la méthode de tableau qui retourne true seulement quand l'enregistrement remplit chaque critère. update doit retourner un nouvel objet sans modifier l'ancien, et find({ zone: 'A', species: 'T-Rex' }) seulement les enregistrements qui remplissent les deux conditions.

  • Clique dans l'ordre

    Range les éléments d'une fabrique simple (pattern Factory) dans le bon ordre :

  • Éditeur de code

    Le fichier index.js contient une classe SecuritySystem avec des capteurs et des observateurs (pattern Observer). Complète les blancs : ___BLANK1___ est la méthode de tableau qui ajoute l'observateur dans subscribe, ___BLANK2___ est la méthode de l'observateur que notify appelle avec l'alerte, et ___BLANK3___ est l'opérateur de comparaison qui ne donne une alerte qu'à un capteur dont la mesure est supérieure au seuil (une mesure égale au seuil n'est pas une alarme). checkAlerts doit retourner les alertes { sensorId, zone, value } et notifier tous les observateurs de chacune, et triggerEmergencyProtocol(reason) doit activer le mode d'urgence et envoyer aux observateurs l'alerte { type: 'emergency', reason }.

Articles utiles