Clean code et refactoring - 05 - Immutabilite et effets de bord : moins de surprises, moins de bugs

Fonctions pures, immutabilité, spread vs structuredClone, Object.freeze : comment réduire les bugs lies aux mutations et effets de bord.

  1. 01 Clean code et refactoring - 00 - Pourquoi le clean code est un investissement, pas un luxe
  2. 02 Clean code et refactoring - 01 - Nommage : la competence la plus sous-estimee
  3. 03 Clean code et refactoring - 02 - Fonctions : courtes, claires, responsables
  4. 04 Clean code et refactoring - 03 - Conditions et lisibilité : sortir de la pyramide
  5. 05 Clean code et refactoring - 04 - Commentaires et documentation : quand le code ne suffit pas
  6. 06 Clean code et refactoring - 05 - Immutabilite et effets de bord : moins de surprises, moins de bugs
  7. 07 Clean code et refactoring - 06 - Gestion des erreurs propre : fail fast, fail loud
  8. 08 Clean code et refactoring - 07 - Programmation defensive vs offensive : valider aux frontieres, faire confiance a l'intérieur
  9. 09 Clean code et refactoring - 08 - SOLID en pratique avec TypeScript
  10. 10 Clean code et refactoring - 09 - DRY, KISS, YAGNI
  11. 11 Clean code et refactoring - 10 - Couplage et cohesion
  12. 12 Clean code et refactoring - 11 - Complexite cyclomatique
  13. 13 Clean code et refactoring - 12 - Abstractions prematurees vs tardives
  14. 14 Clean code et refactoring - 13 - Code smells
  15. 15 Clean code et refactoring - 14 - Techniques de refactoring
  16. 16 Clean code et refactoring - 15 - Refactoring legacy sans tout casser
  17. 17 Clean code et refactoring - 16 - Tests comme filet de sécurité pour le refactoring
  18. 18 Clean code et refactoring - 17 - Structurer un projet — feature-based vs layer-based
  19. 19 Clean code et refactoring - 18 - Constantes, configuration et magic numbers
  20. 20 Clean code et refactoring - 19 - Linting et formatting — ESLint, Biome, automatiser la qualité
  21. 21 Clean code et refactoring - 20 - Conventions d'équipe et ADR
  22. 22 Clean code et refactoring - 21 - Dette technique — quand elle est acceptable, quand elle tue le projet
  23. 23 Clean code et refactoring - 22 - Code review — donner et recevoir du feedback
  24. 24 Clean code et refactoring - 23 - Glossaire — tous les termes de la serie

05 - Immutabilite et effets de bord : moins de surprises, moins de bugs

Ce que tu vas apprendre

  • Ce qu'est un effet de bord et pourquoi c'est une source de bugs
  • Les fonctions pures : meme entree, meme sortie, toujours
  • Pourquoi l'immutabilité réduit les bugs
  • Les outils TypeScript pour l'immutabilité : readonly, as const, Readonly
  • spread vs structuredClone : quand utiliser quoi
  • Pourquoi const ne veut pas dire immutable

Prerequisites

04 - Commentaires et documentation


Un bug m'a rendu fou pendant deux jours en 2022. L'application affichait le mauvais total dans le panier. Le calcul etait correct quand on testait la fonction de calcul isolement. Mais en production, le total etait faux. Intermittent. Pas reproductible de manière fiable.

Le coupable :

typescriptfunction applyDiscount(cart: Cart): Cart {
  cart.items.forEach((item) => {
    item.price = item.price * 0.9; // mutation directe
  });
  return cart;
}

Cette fonction mutait les objets originaux. Le composant qui affichait la liste des produits et le composant qui affichait le total partageaient les memes références. Appeler applyDiscount deux fois (a cause d'un re-render React) appliquait la remise deux fois. 10% de remise devenait 19%.

Une mutation cachee. Deux jours de debug.

Qu'est-ce qu'un effet de bord ?

Un effet de bord, c'est toute interaction entre une fonction et le monde extérieur. Concretement :

  • Modifier un paramètre reçu
  • Modifier une variable extérieure a la fonction
  • Écrire en base de donnees
  • Envoyer une requête HTTP
  • Écrire dans la console (console.log)
  • Lire ou écrire un fichier
  • Modifier le DOM
  • Lancer un timer (setTimeout, setInterval)

Les effets de bord ne sont pas mauvais en soi. Une application sans effets de bord ne ferait rien d'utile : pas de base, pas d'API, pas d'affichage. Le problème, c'est les effets de bord caches. Ceux que l'appelant ne voit pas venir.

Fonctions pures : la base

Une fonction pure a deux propriétés :

  1. Meme entree = meme sortie, toujours
  2. Aucun effet de bord
typescript// Fonction pure
function calculateTotal(items: readonly Item[]): number {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

// Fonction pure
function formatName(firstName: string, lastName: string): string {
  return `${firstName} ${lastName}`;
}

// Fonction pure
function filterActiveUsers(users: readonly User[]): User[] {
  return users.filter((user) => user.isActive);
}

Ces fonctions sont testables sans mock, sans setup, sans teardown. Tu passes des valeurs, tu verifies le résultat. C'est tout. On a parle de ce principe dans l'article sur les fonctions.

typescript// Fonctions impures
function getRandomId(): string {
  return Math.random().toString(36); // sortie differente a chaque appel
}

function getCurrentUser(): User {
  return globalState.currentUser; // depend d'un etat externe
}

function saveUser(user: User): void {
  database.save(user); // effet de bord : ecriture en base
}

`const` ne veut pas dire immutable

C'est le piège classique. const empeche la reassignation de la variable. Il ne protégé pas le contenu de l'objet.

typescriptconst user = { name: "Alice", age: 30 };
// user = { name: "Bob" };  // Erreur : reassignation interdite
user.name = "Bob";           // OK : mutation autorisee
user.age = 31;               // OK : mutation autorisee

const items = [1, 2, 3];
// items = [4, 5, 6];       // Erreur
items.push(4);               // OK : mutation autorisee
items[0] = 99;               // OK : mutation autorisee

const est un faux ami pour l'immutabilité. Utilise-le quand meme (c'est mieux que let), mais ne pense pas que ton objet est protégé.

Spread operator : copie superficielle

Le spread créé une copie de surface (shallow copy) :

typescriptconst original = { name: "Alice", age: 30 };
const copy = { ...original, age: 31 };
// original.age === 30, copy.age === 31 - pas de mutation

const items = [1, 2, 3];
const newItems = [...items, 4];
// items === [1, 2, 3], newItems === [1, 2, 3, 4]

Le problème : les objets imbriques sont partages par référencé.

typescriptconst original = {
  name: "Alice",
  address: { city: "Paris", zip: "75001" },
};

const copy = { ...original };
copy.address.city = "Lyon";

console.log(original.address.city); // "Lyon" - oups !

Le spread a copie la référencé vers address, pas l'objet address lui-meme. Les deux pointent vers le meme objet en mémoire.

`structuredClone` : copie profonde

Depuis Node 17 et tous les navigateurs modernes, structuredClone fait une copie profonde :

typescriptconst original = {
  name: "Alice",
  address: { city: "Paris", zip: "75001" },
  tags: ["admin", "premium"],
};

const copy = structuredClone(original);
copy.address.city = "Lyon";
copy.tags.push("beta");

console.log(original.address.city); // "Paris" - safe
console.log(original.tags);         // ["admin", "premium"] - safe

Quand utiliser quoi :

Situation Outil
Objet plat (pas d'imbrication) Spread { ...obj }
Ajout a un tableau Spread [...arr, newItem]
Objet avec imbrication structuredClone(obj)
Performance critique (boucle chaude) Spread (plus rapide)

`Object.freeze` : immutabilité a l'exécution

typescriptconst config = Object.freeze({
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retries: 3,
});

config.timeout = 10000; // silencieusement ignore (ou TypeError en strict mode)

Attention : Object.freeze est superficiel, comme le spread.

typescriptconst config = Object.freeze({
  api: { url: "https://api.example.com" },
});

config.api.url = "https://evil.com"; // ca marche ! freeze est superficiel

Pour un freeze profond, il faut une fonction recursive :

typescriptfunction deepFreeze<T extends object>(obj: T): Readonly<T> {
  Object.freeze(obj);
  for (const value of Object.values(obj)) {
    if (typeof value === "object" && value !== null) {
      deepFreeze(value);
    }
  }
  return obj;
}

TypeScript : `readonly` et `as const`

TypeScript offre l'immutabilité a la compilation. Pas de coût a l'exécution.

typescript// readonly sur les proprietes
type User = {
  readonly id: string;
  readonly email: string;
  name: string; // celui-ci reste mutable
};

const user: User = { id: "1", email: "a@b.com", name: "Alice" };
// user.id = "2";    // Erreur de compilation
user.name = "Bob";   // OK

// Readonly<T> rend toutes les proprietes readonly
type ImmutableUser = Readonly<User>;

// ReadonlyArray<T> ou readonly T[]
function processItems(items: readonly number[]): number {
  // items.push(4);   // Erreur de compilation
  // items[0] = 99;   // Erreur de compilation
  return items.reduce((a, b) => a + b, 0); // OK, lecture seule
}

as const est mon outil préféré. Il rend tout readonly et infere les types les plus precis possibles :

typescript// Sans as const
const ROLES = ["admin", "editor", "viewer"]; // type: string[]

// Avec as const
const ROLES = ["admin", "editor", "viewer"] as const;
// type: readonly ["admin", "editor", "viewer"]

type Role = (typeof ROLES)[number]; // "admin" | "editor" | "viewer"

// Pareil pour les objets
const HTTP_STATUS = {
  OK: 200,
  NOT_FOUND: 404,
  SERVER_ERROR: 500,
} as const;

type StatusCode = (typeof HTTP_STATUS)[keyof typeof HTTP_STATUS]; // 200 | 404 | 500

J'utilise as const presque partout. Les gains en type safety sont énormes pour zero effort. Si tu veux approfondir les patterns TypeScript avances, j'ai écrit des articles dédiés sur paltemps.fr.

Patterns de gestion d'état immutable

En React ou dans n'importe quel système de gestion d'état :

typescript// Mauvais : mutation directe
function addItem(state: AppState, item: Item): AppState {
  state.cart.items.push(item); // mutation !
  state.cart.total += item.price; // mutation !
  return state; // meme reference, React ne detecte pas le changement
}

// Bon : creation d'un nouvel objet
function addItem(state: AppState, item: Item): AppState {
  return {
    ...state,
    cart: {
      ...state.cart,
      items: [...state.cart.items, item],
      total: state.cart.total + item.price,
    },
  };
}

La version immutable est plus verbeuse. C'est le prix. Mais le gain est énorme : pas de mutation accidentelle, React détecté le changement, le debugging est simple (tu peux comparer l'ancien et le nouvel état), et tu peux implementer undo/redo facilement.

Pour les états complexes, des librairies comme Immer simplifient la syntaxe :

typescriptimport { produce } from "immer";

const nextState = produce(state, (draft) => {
  draft.cart.items.push(item);
  draft.cart.total += item.price;
});
// nextState est un nouvel objet, state n'a pas bouge

Résumé

  • Un effet de bord est toute interaction avec le monde extérieur
  • Les fonctions pures sont testables, previsibles, composables
  • const empeche la reassignation, pas la mutation
  • Spread fait une copie superficielle, structuredClone une copie profonde
  • Object.freeze est superficiel aussi
  • readonly, Readonly<T>, et as const apportent l'immutabilité a la compilation sans coût runtime
  • Prefere la création de nouveaux objets a la mutation des existants

Article précédent : 04 - Commentaires et documentation

Article suivant : 06 - Gestion des erreurs propre

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.