Accueil / Articles / Ce que JSON.stringify omet silencieusement, convertit et refuse de serialiser

Ce que JSON.stringify omet silencieusement, convertit et refuse de serialiser

Découvrez quels valeurs JavaScript JSON.stringify omet ou modifie, comment toJSON, les remplaçants et les restaurateurs y remédient, et quand structuredClone est l’outil le plus adapté.

1602 mots

JSON.stringify ne se plaint que rarement. Lorsqu’il rencontre une valeur qu’il ne peut pas représenter, il l’omet généralement ou la convertit en quelque chose d’autre avant de continuer, renvoyant ainsi un JSON parfaitement valide. C’est ainsi que les bugs apparaissent à plusieurs étapes de distance de leur cause : les données disparaissent pendant la sérialisation, mais l’erreur se manifeste plus tard dans le code, qui n’a aucune idée qu’une sérialisation a eu lieu. Ce guide recense ce qui est perdu, ce dont le type change et ce qui provoque des erreurs, puis présente les outils intégrés (toJSON, remplaçants, réinitialisateurs et structuredClone) qui vous permettent de gérer délibérément chaque cas.

Propriétés qui disparaissent sans laisser de trace

Considérons un objet de session contenant un mélange de données ordinaires, une fonction de rappel, un champ explicitement défini comme undefined et un symbole :

const session = {
  userId: 42,
  role: "admin",
  onExpire: () => console.log("session expired"),
  lastActivity: undefined,
  tempToken: Symbol("temp"),
};

console.log(JSON.stringify(session));
// {"userId":42,"role":"admin"}

La sortie ne contient que deux des cinq propriétés. onExpire, lastActivity et tempToken ont disparu, sans aucune exception, sans avertissement et sans rien dans la chaîne de caractères résultante indiquant que quelque chose a été supprimé. À l’intérieur d’un objet, JSON.stringify ignore toute propriété dont la valeur est undefined, une fonction ou un Symbol. Aucune de ces valeurs n’a de représentation dans le format JSON, et au lieu d’échouer, le sérialiseur renvoie un JSON valide pour ce qui reste.

La conséquence est subtile. Un utilisateur qui analyse cette chaîne de caractères et s’attend à trouver lastActivity, même si sa valeur est undefined, ne le trouvera pas. La clé n’est pas vide ; elle n’a jamais été incluse dans la sortie en premier lieu. Le code qui distingue un élément « manquant » d’un élément « présent mais undefined », par exemple avec « lastActivity » in obj ou Object.keys, se comportera désormais différemment de l’autre côté.

Les tableaux se comportent différemment

Les mêmes valeurs non prises en charge sont traitées différemment à l’intérieur d’un tableau :

console.log(JSON.stringify([undefined, function () {}, 1]));
// [null,null,1]

Ici, ils deviennent null au lieu de disparaître. Un tableau ne peut pas supprimer un élément sans déplacer les indices de tous les éléments suivants, c’est pourquoi le sérialiseur conserve la place et la remplit avec ce que JSON a de plus proche de « rien ». La cause racine est identique, mais on observe deux comportements silencieux distincts selon que la valeur se trouvait dans un objet ou dans un tableau. Deux cas limites liés suivent le même principe : NaN et Infinity sont sérialisés en null, et l’appel direct de JSON.stringify sur undefined ou une fonction renvoie undefined au lieu d’une chaîne de caractères.

Les dates reviennent sous forme de chaînes

Il semble que les dates survivent à un aller-retour, mais seulement en partie :

const record = { createdAt: new Date() };
const json = JSON.stringify(record);
console.log(json); // {"createdAt":"2026-09-07T14:30:00.000Z"}

const restored = JSON.parse(json);
console.log(restored.createdAt instanceof Date); // false
console.log(typeof restored.createdAt);          // "string"

Les informations de date elles-mêmes restent intactes ; elles apparaissent bien dans la sortie sous forme de chaîne ISO 8601. Ce qui est perdu, c’est le type. JSON.parse ne peut pas savoir qu’une certaine chaîne était à l’origine un objet Date plutôt que du texte qui en a simplement l’apparence, c’est pourquoi il renvoie une chaîne, qui reste telle quelle à moins qu’un mécanisme ne la convertisse à nouveau.

Cela devient problématique dès que du code appelle une méthode liée à la date après ce cycle de conversion. Des expressions comme record.createdAt.getFullYear() génèrent une erreur, non pas parce que les données sont incorrectes, mais parce que leur type a changé silencieusement au cours du processus. Ce phénomène se produit fréquemment avec les réponses API, les valeurs restaurées depuis localStorage, ainsi que les messages transmis via des files d’attente.

Les structures circulaires provoquent également des erreurs

Tous les échecs ne se manifestent pas de manière discrète. Certains objets ne peuvent tout simplement pas être serialisés, et le moteur de traitement l’indique clairement :

const parent = { name: "parent" };
const child = { name: "child", parent };
parent.child = child;

JSON.stringify(parent); // TypeError: Converting circular structure to JSON

Pour générer sa sortie, JSON.stringify parcourt l’arbre des objets. Lorsqu’un objet de cet arbre renvoie à l’un de ses ancêtres, cette parcours ne s’arrêtera jamais. Plutôt que de continuer à récurser indéfiniment, le moteur détecte ce cycle et lance immédiatement une TypeError. C’est l’un des rares cas où le sérialiseur échoue honnêtement, et pour une bonne raison : il n’existe pas de réponse partielle ou approximative à fournir, car un arbre cyclique ne peut véritablement pas être aplati en un arbre JSON. Les valeurs BigInt constituent un autre cas problématique ; elles provoquent également une TypeError à moins de les convertir soi-même.

Contrôler la sortie avec toJSON

Certains types intégrés déterminent déjà leur format JSON, c’est pourquoi un Date se transforme en chaîne ISO plutôt qu’en objet vide. Tout objet peut adopter ce même comportement en définissant une méthode toJSON. Lorsqu’elle existe, JSON.stringify l’appelle et sérialise sa valeur de retour au lieu des propriétés propres à l’objet :

class Money {
  constructor(cents) {
    this.cents = cents;
  }
  toJSON() {
    return { amount: this.cents / 100, currency: "USD" };
  }
}

const price = new Money(3499);
console.log(JSON.stringify({ price })); // {"price":{"amount":34.99,"currency":"USD"}}

En l’absence de toJSON, l’instance Money serait écrite selon sa structure interne, {"cents":3499}, révélant ainsi un détail d’implémentation sur lequel les autres parties du système ne devraient jamais compter. Avec cette méthode, l’objet définit sa propre représentation publique. Date utilise précisément ce mécanisme : c’est Date.prototype.toJSON qui génère la chaîne ISO.

N’oubliez pas que c’est un processus unidirectionnel. L’analyse du résultat vous fournit un objet simple contenant amount et currency, et non une instance de Money ; la reconstruction de cette classe relève du rôle du mécanisme de restauration décrit ci-après.

Remplaçants et mécanismes de restauration : gérer les deux directions

JSON.stringify accepte un deuxième argument optionnel, une fonction de remplacement, qui est appelée pour chaque clé et valeur avant qu’elles ne soient écrites. En renvoyant undefined, cette fonction supprime l’entrée correspondante, tandis qu’en renvoyant toute autre valeur, elle la remplace. Cela constitue un moyen simple de filtrer les champs sensibles lors de l’exportation :

const user = { id: 1, name: "Priya", passwordHash: "a1b2c3..." };

const safe = JSON.stringify(user, (key, value) => {
  return key === "passwordHash" ? undefined : value;
});
console.log(safe); // {"id":1,"name":"Priya"}

JSON.parse offre l’inverse : un mécanisme de restauration, appelé pour chaque clé et valeur après l’analyse. C’est la solution officielle au problème Date présenté précédemment :

const restored = JSON.parse(json, (key, value) => {
  if (key === "createdAt") return new Date(value);
  return value;
});
console.log(restored.createdAt instanceof Date); // true

Deux points importants méritent d’être mentionnés. Les outils de réanimation s’exécutent de bas en haut, ce qui signifie que les valeurs imbriquées sont déjà réanimées lorsque leur parent est traité. De plus, une vérification basée uniquement sur le nom de la clé permet de trouver cette clé à n’importe quelle profondeur ; par conséquent, un champ createdAt présent dans un objet imbriqué sera également converti. Si ce n’est pas ce que vous souhaitez, vérifiez également le format de la valeur.

Aucun de ces aspects n’est un coin obscur de l’API. Ce sont les méthodes prévues pour contrôler précisément la sérialisation, et elles sont bien plus fiables que de supprimer des propriétés avant de les transformer en chaîne de caractères ou de modifier manuellement des objets après leur analyse.

Map et Set perdent tout

Les développeurs qui s’attendent à ce que JSON conserve n’importe quel type d’objet se retrouvent souvent dans cette situation :

const tags = new Set(["urgent", "billing"]);
console.log(JSON.stringify({ tags })); // {"tags":{}}

Pour le sérialiseur, un Set n’est ni un tableau ni un objet ordinaire possédant des propriétés propres énumérables, ce qui le transforme en un objet vide et fait que toutes les valeurs qu’il contenait sont silencieusement éliminées. Un Map subit le même sort. Si l’un ou l’autre doit être conservé, il faut le convertir explicitement avant de le transformer en chaîne de caractères, par exemple en le transformant en tableau :

const json = JSON.stringify({ tags: [...tags] }); // convert Set to array before stringifying

Pour un Map, [...map] ou Object.fromEntries(map) produisent des formes sérialisables, et un mécanisme de réinitialisation peut reconstruire la collection d’origine lors du processus inverse.

Copies profondes : utilisez structuredClone

Pendant des années, JSON.parse(JSON.stringify(obj)) a été une méthode courante pour cloner profondément un objet. Elle ne fonctionne que par coïncidence et subit toutes les limitations mentionnées ci-dessus : les fonctions et les valeurs undefined disparaissent, les dates se transforment en chaînes de caractères, les collections s’évident, et les références circulaires provoquent des erreurs.

Les navigateurs modernes et Node.js offrent une alternative conçue spécifiquement à cet effet :

const clone = structuredClone(original);

structuredClone effectue une véritable copie profonde en utilisant l’algorithme de clonage structuré. Il préserve les types Date, Map et Set, ainsi que les références circulaires, éléments que la méthode JSON soit déforme soit rejette. Cependant, il présente aussi ses propres limites : il lance une erreur DataCloneError pour les fonctions et les nœuds DOM, et les instances de classe sont renvoyées sous forme d’objets ordinaires sans leur prototype. Lorsque l’objectif est simplement de copier des données, structuredClone est presque toujours le meilleur choix. JSON.stringify n’a jamais été conçu pour le clonage ; il était simplement suffisamment pratique pour que les gens l’utilisent de cette manière.

Points clés

  • JSON.stringify sérialise uniquement le sous-ensemble de valeurs JavaScript que JSON peut représenter, et non n’importe quelles valeurs JavaScript.
  • Dans les objets, undefined, les fonctions et les symboles sont supprimés ; dans les tableaux, ils deviennent null.
  • Les dates survivent sous forme de chaînes ISO mais perdent leur type ; utilisez un outil de restauration pour les rétablir.
  • Les références circulaires et BigInt provoquent une erreur ; Map et Set sont sérialisés silencieusement en {}.
  • Utilisez toJSON pour définir la structure publique d’un objet, un remplaçant pour filtrer la sortie, et un outil de restauration pour rétablir les types.
  • Recourez à structuredClone lorsque vous souhaitez une copie, et considérez JSON.stringify comme un filtre de formatage dont vous devez gérer manuellement les lacunes, afin que les champs, les collections et les types ne disparaissent pas silencieusement entre différentes parties de votre système.
  • Lectures complémentaires

  • Que fait vraiment npm install : le registre, package.json et les lockfiles — Une présentation pratique de npm : le registre et l’interface en ligne de commande, la manière dont npm install résout les paquets, ce que record package.json et package-lock.json, ainsi que la façon de publier.