TypeScript le système de types - 03 - Valeurs vs références : copies, mutations et spread traps

Comment JavaScript gere les objets en mémoire, pourquoi muter un argument modifie l'original, et les pièges du spread operator.

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 :

  1. Ne mute jamais un argument de fonction. Cree une copie si tu dois modifier.
  2. Utilise structuredClone pour les copies profondes de donnees.
  3. Pour les copies simples (un niveau), le spread suffit.
  4. Retourne de nouveaux objets plutot que de muter les existants (pattern fonctionnel).
  5. 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)
  • structuredClone fait une copie profonde mais ne gere pas les fonctions ni les prototypes de classes
  • TypeScript ne signale pas les mutations d'arguments — utilise readonly pour te protéger

Article précédent : 02 - Inference, widening et narrowing

Article suivant : 04 - Immutabilite et readonly

Sources

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