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-equaloutoEqualdans 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