03 - Valeurs vs références : copies, mutations et spread traps
Ce que tu vas apprendre
- La différence entre types primitifs (par valeur) et objets (par référencé)
- Pourquoi muter un objet dans une fonction modifie l'original
- Les pièges du shallow copy avec le spread operator
- Comment faire une copie profonde avec
structuredClone
Prerequisites
Avoir lu l'article sur l'inference et le narrowing.
Le bug qui m'a coûte 3 heures
En 2024, je bossais sur un système de tarification pour un client. La fonction applyDiscount prenait un objet Order et appliquait une réduction. Le code etait simple :
typescriptfunction applyDiscount(order: Order, percent: number) {
order.total = order.total * (1 - percent / 100)
return order
}
Le problème : cette fonction etait appelee dans une boucle de simulation. On testait différents pourcentages de réduction pour afficher un tableau comparatif. Sauf que chaque appel modifiait l'objet original. La première itération appliquait -10%, la deuxieme appliquait -20% sur le montant deja réduit, et a la fin les chiffres etaient complètement faux.
TypeScript n'avait rien signale. Le code etait valide du point de vue des types. Le bug etait dans la semantique des références.
Primitifs : par valeur
Les types primitifs en JavaScript (et donc en TypeScript) sont copies par valeur :
typescriptlet a = 42
let b = a // b recoit une copie de 42
b = 100
console.log(a) // 42 — a n'a pas change
console.log(b) // 100
Les primitifs sont : string, number, boolean, bigint, symbol, undefined, null. Quand tu assignes un primitif a une variable ou que tu le passes a une fonction, tu travailles sur une copie. L'original ne bouge pas.
typescriptfunction double(n: number) {
n = n * 2
return n
}
const x = 5
const y = double(x)
console.log(x) // 5 — x n'a pas change
console.log(y) // 10
Objets : par référencé
Les objets, tableaux, Map, Set, Date, et toute instance de classe sont passes par référencé. La variable ne contient pas l'objet, elle contient un pointeur vers l'objet en mémoire.
typescriptconst user = { name: "Nicolas", age: 31 }
const copy = user // copy pointe vers le MEME objet
copy.age = 32
console.log(user.age) // 32 — l'original a change
Quand tu ecris const copy = user, tu ne copies pas l'objet. Tu copies la référencé. Les deux variables pointent vers la meme zone mémoire. Modifier l'un modifie l'autre.
user ──┐
├──→ { name: "Nicolas", age: 32 }
copy ──┘
C'est la meme chose quand tu passes un objet a une fonction :
typescriptfunction rename(user: { name: string }, newName: string) {
user.name = newName
}
const alice = { name: "Alice" }
rename(alice, "Alicia")
console.log(alice.name) // "Alicia" — l'original est modifie
La fonction n'a pas reçu une copie de alice. Elle a reçu la référencé. La mutation est visible partout.
Le spread operator : shallow copy
Le spread (...) créé une copie superficielle (shallow copy). Il copie les propriétés de premier niveau, mais pas les objets imbriques.
typescriptconst original = {
name: "Nicolas",
settings: {
theme: "dark",
lang: "fr"
}
}
const copy = { ...original }
copy.name = "Alice"
console.log(original.name) // "Nicolas" ✅ — copie independante
copy.settings.theme = "light"
console.log(original.settings.theme) // "light" ❌ — settings est partage
Le spread a copie name (un primitif, donc par valeur) et settings (un objet, donc par référencé). copy.settings et original.settings pointent vers le meme objet.
original ──→ { name: "Nicolas", settings: ──→ { theme: "light", lang: "fr" } }
copy ──→ { name: "Alice", settings: ──┘ }
C'est le piège classique. Meme chose avec les tableaux :
typescriptconst matrix = [[1, 2], [3, 4]]
const copy = [...matrix]
copy[0].push(99)
console.log(matrix[0]) // [1, 2, 99] — le sous-tableau est partage
Le spread pour les tableaux d'objets
Un cas frequent en React et dans les APIs :
typescriptconst users = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" }
]
const copy = [...users]
copy[0].name = "Alicia"
console.log(users[0].name) // "Alicia" — les objets sont partages
Le spread copie les références des objets dans un nouveau tableau. Pour avoir des objets independants :
typescriptconst copy = users.map(u => ({ ...u }))
copy[0].name = "Alicia"
console.log(users[0].name) // "Alice" ✅
Mais ca ne resout le problème que sur un niveau. Si les objets ont des propriétés imbriquees, tu retombes dans le piège.
structuredClone : la copie profonde
Depuis 2022, JavaScript a structuredClone. Il copie un objet en profondeur, recursivement, sans partage de références.
typescriptconst original = {
name: "Nicolas",
settings: {
theme: "dark",
notifications: {
email: true,
push: false
}
}
}
const copy = structuredClone(original)
copy.settings.notifications.push = true
console.log(original.settings.notifications.push) // false ✅
structuredClone fonctionne avec les objets, tableaux, Map, Set, Date, RegExp, ArrayBuffer, et les structures imbriquees. Il ne copie pas les fonctions, les symboles en tant que clés, ni les instances de classes personnalisees (il perd le prototype).
typescript// ❌ Ne fonctionne pas avec les fonctions
const obj = { greet: () => "hello" }
structuredClone(obj) // DataCloneError
// ❌ Perd le prototype des classes
class User {
constructor(public name: string) {}
greet() { return `Hi, ${this.name}` }
}
const user = new User("Nicolas")
const copy = structuredClone(user)
copy.greet() // TypeError: copy.greet is not a function
Pour la plupart des cas (donnees JSON, state React, objets de configuration), structuredClone est la bonne solution. Oublie JSON.parse(JSON.stringify(x)) qui ne gere ni les Date, ni les Map, ni les undefined.
TypeScript ne te protégé pas des mutations
Le système de types ne fait pas la différence entre "lire" et "écrire" une propriété par défaut. Quand tu passes un objet a une fonction, TypeScript te laisse le muter sans broncher :
typescriptfunction process(items: string[]) {
items.sort() // mute le tableau original
items.push("done") // ajoute a l'original
return items
}
const myItems = ["c", "a", "b"]
process(myItems)
console.log(myItems) // ["a", "b", "c", "done"] — mute
Le sort() de JavaScript mute le tableau en place. process a modifie myItems sans que le type ne l'indique. Pour se protéger, il faut utiliser readonly ou Readonly<T>, ce que couvre l'article suivant.
Regles pratiques
Quelques principes que j'applique dans mes projets :
- Ne mute jamais un argument de fonction. Cree une copie si tu dois modifier.
- Utilise
structuredClonepour les copies profondes de donnees. - Pour les copies simples (un niveau), le spread suffit.
- Retourne de nouveaux objets plutot que de muter les existants (pattern fonctionnel).
- Si une fonction doit muter, nomme-la explicitement (
sortInPlace,resetUser).
typescript// ❌ Mute l'argument
function applyDiscount(order: Order, percent: number) {
order.total = order.total * (1 - percent / 100)
return order
}
// ✅ Retourne un nouvel objet
function applyDiscount(order: Order, percent: number): Order {
return {
...order,
total: order.total * (1 - percent / 100)
}
}
Le deuxieme style est compatible avec React (immutabilité du state), avec Redux, et avec le pattern fonctionnel en général. Il évité les bugs de références partagees comme celui de mon histoire au début.
Résumé
- Les primitifs (string, number, boolean...) sont copies par valeur — modifier la copie ne touche pas l'original
- Les objets et tableaux sont passes par référencé — modifier un argument mute l'original
- Le spread (
...) ne fait qu'une copie superficielle (un seul niveau) structuredClonefait une copie profonde mais ne gere pas les fonctions ni les prototypes de classes- TypeScript ne signale pas les mutations d'arguments — utilise
readonlypour te protéger
Article précédent : 02 - Inference, widening et narrowing
Article suivant : 04 - Immutabilite et readonly