TypeScript le système de types - 05 - Egalite structurelle vs referentielle

Pourquoi === ment sur les objets, comment fonctionne l'egalite structurelle, et l'impact sur React, les Map, les Set et les tests.

05 - Egalite structurelle vs referentielle

Ce que tu vas apprendre

  • Pourquoi === compare les références sur les objets, pas le contenu
  • L'impact sur les re-renders React, les Map, les Set et les conditions
  • Comment implementer une comparaison structurelle (deep equal)
  • Ce que TypeScript garantit (et ne garantit pas) sur l'egalite

Prerequisites

Avoir lu l'article sur les valeurs vs références et sur l'immutabilité.


Le composant qui re-rend a l'infini

Sur un projet paltemps.fr, j'avais un composant React qui affichait des statistiques. Il recevait un objet filters en prop :

typescriptfunction Dashboard({ filters }: { filters: Filters }) {
  const data = useMemo(() => fetchData(filters), [filters])
  return <Chart data={data} />
}

Le parent recalculait filters a chaque render :

typescriptfunction App() {
  const filters = { period: "month", status: "active" }
  return <Dashboard filters={filters} />
}

Le useMemo ne cachait rien. A chaque render du parent, un nouvel objet filters etait créé. Meme contenu, nouvelle référencé. React voyait une référencé différente dans le tableau de dépendances et relancait fetchData.

Le fix : useMemo sur les filtres dans le parent, ou React.memo avec une fonction de comparaison personnalisee. Mais la cause racine, c'est la confusion entre egalite referentielle et egalite structurelle.

Egalite referentielle : ===

En JavaScript, === compare les références pour les objets :

typescriptconst a = { name: "Nicolas" }
const b = { name: "Nicolas" }
const c = a

console.log(a === b) // false — deux objets differents en memoire
console.log(a === c) // true — meme reference

a et b ont le meme contenu mais sont deux objets distincts. === ne regarde pas a l'intérieur, il compare les adresses mémoire.

Pour les primitifs, === compare les valeurs :

typescriptconsole.log(42 === 42)         // true
console.log("hello" === "hello") // true
console.log(true === true)       // true

C'est pour ca que les primitifs "fonctionnent comme on s'y attend" avec ===, mais pas les objets.

Le cas des tableaux

Les tableaux sont des objets :

typescriptconsole.log([1, 2, 3] === [1, 2, 3]) // false
console.log([] === [])                 // false

const arr = [1, 2]
const ref = arr
console.log(arr === ref) // true — meme reference

Meme chose pour les fonctions :

typescriptconst fn1 = () => 42
const fn2 = () => 42
console.log(fn1 === fn2) // false

Impact en pratique

React : memo, useMemo, useEffect

React utilise === (egalite referentielle) pour comparer les dépendances dans useMemo, useCallback, useEffect, et pour React.memo.

typescript// Ce useEffect se declenche a CHAQUE render
useEffect(() => {
  fetchData(filters)
}, [{ status: "active", period: "month" }])
// nouvel objet a chaque render = nouvelle reference

La solution courante : stabiliser les références avec useMemo ou stocker les valeurs dans un state.

typescriptconst filters = useMemo(
  () => ({ status: "active", period: "month" }),
  [] // cree une seule fois
)

useEffect(() => {
  fetchData(filters)
}, [filters]) // meme reference = pas de re-declenchement

Map et Set avec des objets

Les Map et Set utilisent === pour les clés et les valeurs :

typescriptconst map = new Map<{ id: number }, string>()

map.set({ id: 1 }, "Alice")
map.get({ id: 1 }) // undefined — nouvelle reference, pas la meme cle

const set = new Set<number[]>()
set.add([1, 2])
set.has([1, 2]) // false — nouveau tableau

Si tu veux utiliser des objets comme clés, il faut garder la référencé :

typescriptconst key = { id: 1 }
map.set(key, "Alice")
map.get(key) // "Alice" ✅ — meme reference

Ou utiliser un identifiant primitif comme clé a la place.

Conditions et filtres

Un piège classique dans les conditions :

typescriptconst defaultConfig = { theme: "dark", lang: "fr" }

function hasChanged(current: Config): boolean {
  return current !== defaultConfig // compare les references, pas le contenu
}

hasChanged({ theme: "dark", lang: "fr" }) // true — meme si le contenu est identique

Deep equal : comparer le contenu

Pour comparer deux objets par leur contenu, il faut une comparaison structurelle. JavaScript n'en fournit pas nativement. Plusieurs options :

JSON.stringify (simple mais limite)

typescriptJSON.stringify(a) === JSON.stringify(b)

Ca marche pour des objets simples. Mais l'ordre des clés compte, et ca ne gere ni les undefined, ni les Date, ni les Map, ni les références circulaires.

typescriptconst x = { a: 1, b: 2 }
const y = { b: 2, a: 1 }
JSON.stringify(x) === JSON.stringify(y) // false — ordre different

Librairies : lodash, fast-deep-equal

Pour une comparaison fiable, utilise une lib testee :

typescriptimport isEqual from "lodash/isEqual"

isEqual({ a: 1, b: [2, 3] }, { a: 1, b: [2, 3] }) // true
isEqual({ a: 1 }, { a: 2 }) // false

Ou fast-deep-equal qui est plus legere (pas de dépendance lodash) :

typescriptimport equal from "fast-deep-equal"

equal({ a: 1 }, { a: 1 }) // true

Selon les benchmarks de fast-deep-equal, elle est 2 a 3 fois plus rapide que lodash.isEqual pour les cas courants.

Bun et Node : assert.deepStrictEqual

Dans les tests, les test runners fournissent leur propre deep equal :

typescriptimport { expect, test } from "bun:test"

test("user format", () => {
  const result = formatUser(input)
  expect(result).toEqual({ name: "Nicolas", age: 31 }) // deep equal
})

toEqual fait une comparaison structurelle. toBe fait une comparaison referentielle. C'est une distinction importante dans les tests.

TypeScript et l'egalite structurelle des types

Le système de types de TypeScript est lui-meme structurel. Deux types sont compatibles si leur structure est compatible, pas s'ils ont le meme nom :

typescriptinterface Point {
  x: number
  y: number
}

interface Coordinate {
  x: number
  y: number
}

const p: Point = { x: 1, y: 2 }
const c: Coordinate = p // ✅ meme structure = compatible

C'est l'oppose de langages comme Java ou C# ou deux classes avec le meme contenu sont incompatibles si elles n'ont pas le meme nom (typage nominal). TypeScript regarde la forme, pas le nom.

Ca a des consequences. Un objet avec des propriétés supplementaires est assignable a un type qui en demande moins :

typescriptinterface HasName {
  name: string
}

const user = { name: "Nicolas", age: 31, email: "n@n.dev" }
const named: HasName = user // ✅ user a une propriete name

L'article sur les branded types dans la serie types avances montre comment simuler un typage nominal quand tu en as besoin.

Quand utiliser quelle egalite

Situation Egalite Outil
Primitifs (string, number...) === natif
Meme objet (meme référencé) === natif
Contenu d'objets simples structurelle fast-deep-equal
Tests structurelle expect().toEqual()
React deps/memo referentielle useMemo pour stabiliser
Map/Set clés referentielle utiliser des primitifs comme clés

Résumé

  • === compare les références pour les objets — deux objets identiques en contenu sont !== s'ils sont en mémoire a des adresses différentes
  • React, Map, Set et les dépendances de hooks utilisent tous l'egalite referentielle
  • Pour comparer le contenu, utilise fast-deep-equal ou toEqual dans les tests
  • Le système de types de TypeScript est structurel : deux types avec la meme forme sont compatibles meme s'ils ont des noms différents

Article précédent : 04 - Immutabilite et readonly

Article suivant : 06 - Generics

Sources

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