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 spreadvsstructuredClone: quand utiliser quoi- Pourquoi
constne 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 :
- Meme entree = meme sortie, toujours
- 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
constempeche la reassignation, pas la mutation- Spread fait une copie superficielle,
structuredCloneune copie profonde Object.freezeest superficiel aussireadonly,Readonly<T>, etas constapportent 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
- MDN Web Docs, "structuredClone()" - https://developer.mozilla.org/en-US/docs/Web/API/structuredClone
- TypeScript Handbook, "Mapped Types - Readonly" - https://www.typescriptlang.org/docs/handbook/2/mapped-types.html
- Immer Documentation - https://immerjs.github.io/immer/