12k
All articles

Comment empêcher JSON d'aplatir vos objets

Corrigez l aplatissage JSON en JavaScript avec replacer, reviver, toJSON et context.source pour restaurer Date, Map, Set et BigInt.

OpenReplay Team
OpenReplay Team
Comment empêcher JSON d'aplatir vos objets

JSON.stringify convertit un Date en appelant sa méthode toJSON, qui renvoie une chaîne ISO 8601, et JSON.parse n’a aucune étape correspondante : la valeur revient donc sous forme de chaîne, à moins que vous ne la convertissiez vous-même à l’aide d’un reviver.

Le scénario est presque toujours le même : un objet mis en cache part dans localStorage sans encombre, en ressort sans encombre, puis .getFullYear() lève une exception ou une cellule de tableau affiche Invalid Date. Les données n’ont jamais été corrompues. Elles ont simplement cessé d’être un Date quelque part entre les deux appels.

Cet article couvre les deux moitiés du trajet : toJSON et le replacer à l’aller, le reviver au retour, et le troisième argument du reviver pour les valeurs qui perdent en précision avant même que vous ne les voyiez. Il suppose que vous maîtrisez déjà les bases ; si vous souhaitez les revoir d’abord, consultez how to read and write JSON in JavaScript. Ici, on commence au deuxième argument.

Points clés à retenir

  • JSON.stringify sérialise un Date via toJSON sous forme de chaîne ISO, et JSON.parse renvoie cette chaîne telle quelle, sauf si un reviver la reconvertit.
  • Renvoyer undefined depuis un reviver supprime la clé concernée du résultat : tout reviver a donc besoin d’un return value final pour les clés qu’il ne traite pas.
  • Le reviver s’exécute sur chaque paire clé-valeur, les enfants avant leur parent, puis une dernière fois sur l’ensemble de la valeur analysée sous la clé "".
  • Map et Set se sérialisent en {} : leur restauration exige donc un replacer et un reviver conçus comme une paire cohérente.
  • Le troisième argument du reviver est un objet de contexte dont la propriété source contient le texte JSON d’origine, ce qui permet de lire un grand entier en tant que BigInt avant que Number ne l’arrondisse.

À quoi ressemble un aller-retour JSON défaillant ?

const session = { user: "ada", lastLogin: new Date("2024-03-01T09:30:00Z") };

const wire = JSON.stringify(session);
// '{"user":"ada","lastLogin":"2024-03-01T09:30:00.000Z"}'

const back = JSON.parse(wire);
typeof back.lastLogin; // "string"
back.lastLogin.getFullYear(); // TypeError

La conversion aller ne pose aucun problème. C’est la conversion retour qui n’a aucune idée de ce que la chaîne représentait auparavant.

Qu’est-ce qui survit à la sérialisation JSON, et qu’est-ce qui n’y survit pas ?

La grammaire de JSON ne prévoit pas d’emplacement pour la plupart de ce que contient un objet JavaScript : tout ce qui en sort est donc converti ou supprimé. MDN documente l’ensemble des règles de sérialisation de JSON.stringify ; la troisième colonne ci-dessous indique ce que vous récupérez réellement après un parse.

ValeurCe qu’écrit JSON.stringifyCe que renvoie JSON.parse
Datechaîne ISO via toJSONchaîne
Map, Set, WeakMap, WeakSet{}objet vide
undefined, fonction, symbole dans un objetpropriété omisepropriété absente
Les mêmes valeurs dans un tableaunullnull
NaN, Infinitynullnull
BigIntlève une TypeErrors. o.
Instance de classeobjet simple composé des propriétés propres énumérablesobjet simple, prototype perdu
Propriété dont la clé est un symboleignoréeabsente
Référence circulairelève une TypeErrors. o.
Number, String, Boolean encapsulésprimitive déballéeprimitive

Deux lignes méritent qu’on s’y attarde. Map et Set ressortent sous la forme {} parce que JSON.stringify parcourt les propriétés propres énumérables d’un objet, et leurs entrées ne s’y trouvent pas. Par ailleurs, à l’intérieur d’un objet, undefined, les fonctions et les valeurs de type symbole sont purement et simplement omis, alors qu’à l’intérieur d’un tableau ces mêmes valeurs deviennent null : les index survivent donc, même si les valeurs disparaissent.

toJSON détermine ce qui est écrit

Lorsqu’une valeur possède une méthode toJSON, JSON.stringify écrit ce que cette méthode renvoie et ignore l’objet lui-même. L’exemple toJSON de MDN montre également que la méthode reçoit la clé sous laquelle se trouve sa valeur : un même objet peut donc ressortir différemment selon l’endroit où il apparaît.

class Money {
  constructor(amount, currency) {
    this.amount = amount;
    this.currency = currency;
  }
  format() {
    return `${(this.amount / 100).toFixed(2)} ${this.currency}`;
  }
  toJSON() {
    return { __type: "Money", amount: this.amount, currency: this.currency };
  }
}

JSON.stringify({ total: new Money(4599, "EUR") });
// '{"total":{"__type":"Money","amount":4599,"currency":"EUR"}}'

Le champ __type est le discriminant que le reviver recherchera. L’écrire constitue la moitié « aller » du contrat.

Le reviver de JSON.parse s’exécute au retour

Le reviver est le deuxième argument de JSON.parse, et il est appelé pour chaque paire clé-valeur produite par l’analyse. L’exemple de parcours de MDN illustre l’ordre : les valeurs les plus profondes d’abord, puis leurs conteneurs, et un dernier appel couvre l’ensemble du résultat sous la clé "".

JSON.parse('{"a":1,"b":{"c":2,"d":{"e":3}}}', (key, value) => {
  console.log(JSON.stringify(key));
  return value;
});
// "a", "c", "e", "d", "b", ""

Voici maintenant la règle qui détruit silencieusement les données : renvoyer undefined depuis un reviver fait disparaître la clé correspondante de l’objet ; faites-le lors de l’appel racine et l’analyse entière revient à undefined.

const json = '{"user":"ada","lastLogin":"2024-03-01T09:30:00.000Z"}';

// Destructif : chaque clé non traitée tombe à la fin et est supprimée.
JSON.parse(json, (key, value) => {
  if (key === "lastLogin") return new Date(value);
});
// undefined

// Correct : le return de repli préserve tout le reste.
JSON.parse(json, (key, value) =>
  key === "lastLogin" ? new Date(value) : value,
);
// { user: "ada", lastLogin: Date 2024-03-01T09:30:00.000Z }

Aucune des deux versions ne lève d’exception. C’est précisément ce qui rend la première dangereuse : la perte se manifeste par un champ manquant ou une chaîne ISO brute dans l’affichage, plutôt que par une trace de pile — le genre de défaut qu’un session replay met en évidence bien avant qu’un rapport de bug ne le nomme.

Comment restaurer des instances de classe et des Map ?

Restaurer une véritable instance exige les deux moitiés du trajet : toJSON écrit une étiquette de type à côté des données, et le reviver vérifie cette étiquette puis transmet les champs restants au constructeur.

const reviver = (key, value) =>
  value && value.__type === "Money"
    ? new Money(value.amount, value.currency)
    : value;

JSON.parse('{"total":{"__type":"Money","amount":4599,"currency":"EUR"}}', reviver)
  .total.format(); // "45.99 EUR"

Le même schéma s’applique aux types natifs dépourvus de toJSON. Une Map part sous forme de tableau d’entrées grâce à un replacer et revient via un reviver qui reconnaît un tableau de tableaux.

const flags = new Map([["beta", true], ["darkMode", false]]);

const text = JSON.stringify({ flags }, (key, value) =>
  value instanceof Map ? Array.from(value.entries()) : value,
);
// '{"flags":[["beta",true],["darkMode",false]]}'

const restored = JSON.parse(text, (key, value) =>
  Array.isArray(value) && value.every(Array.isArray) ? new Map(value) : value,
);
restored.flags.get("beta"); // true

Ce test de forme relève de la devinette, et il se trompe sur les tableaux vides : [].every(Array.isArray) vaut true, donc un simple [] situé n’importe où dans la charge utile revient sous forme de Map vide. Une étiquette de type, comme celle qu’écrit Money, élimine toute approximation.

Le replacer et le reviver forment un seul et même accord sur un format de transport. Modifiez l’un sans l’autre et l’aller-retour se casse.

Le replacer : filtrer à l’aller

Le replacer est le deuxième argument de JSON.stringify et prend deux formes. En tant que fonction, il s’exécute pour chaque paire clé-valeur, et renvoyer undefined omet la propriété. En tant que tableau, il fait office de liste d’autorisation, où seules les entrées de type chaîne et nombre comptent : tout autre élément placé dans la liste, symboles compris, n’a strictement aucun effet.

const account = { id: 7, email: "ada@example.com", password: "hunter2" };

JSON.stringify(account, (key, value) => (key === "password" ? undefined : value));
// '{"id":7,"email":"ada@example.com"}'

JSON.stringify(account, ["id", "email"]);
// '{"id":7,"email":"ada@example.com"}'

La même technique permet d’écarter une clé de rétro-référence connue qui, sinon, ferait lever une TypeError à JSON.stringify sur un cycle. Un sérialiseur véritablement résistant aux cycles nécessite un WeakSet d’objets déjà visités ; écarter une clé nommée ne traite que le cas dont vous avez connaissance.

Un détail de chronologie a son importance : toJSON s’exécute avant que le replacer ne voie une valeur. Pour un Date, l’argument value du replacer est donc déjà la chaîne ISO, alors que this[key] reste l’objet d’origine.

JSON.stringify({ lastLogin: new Date() }, function (key, value) {
  // Doit être une fonction classique : une fonction fléchée n'a pas de liaison `this` ici.
  return key === "lastLogin" ? this[key].getTime() : value;
});

Le troisième argument, space, n’affecte que la mise en forme. Demandez plus de 10 espaces et vous n’en obtiendrez que 10 ; une chaîne d’indentation de plus de 10 caractères est tronquée à ses 10 premiers caractères.

Lire le texte d’origine avec context.source

Le troisième argument du reviver est un objet de contexte, reconstruit à chaque appel, dont la propriété source contient le texte JSON d’origine de la valeur. Cet argument n’apparaît que pour les primitives ; un objet ou un tableau n’obtient rien. Il s’agit de la proposition TC39 d’accès au texte source de JSON.parse, parvenue au Stage 4 et intégrée dans ECMAScript 2026, approuvé par Ecma International le 30 juin 2026.

Elle résout une perte qui survient avant qu’aucun reviver ne puisse intervenir : au moment où vous recevez value, un grand entier a déjà été arrondi en double.

const wire = '{"orderId": 9007199254740993}';

JSON.parse(wire).orderId;
// 9007199254740992  <- précision déjà perdue

JSON.parse(wire, (key, value, context) =>
  key === "orderId" ? BigInt(context.source) : value,
).orderId;
// 9007199254740993n

value est le produit dégradé. context.source correspond à ce qui circulait réellement. Vérifiez la disponibilité de cette fonctionnalité dans vos environnements d’exécution cibles avant de compter dessus.

Conclusion

La sérialisation est un contrat que vous écrivez deux fois : une fois dans toJSON ou un replacer, une fois dans un reviver qui comprend ce que la première moitié a produit. Passez en revue les objets que vous poussez dans localStorage ou dans une couche de cache, repérez ceux qui transportent des Date, des Map, des Set ou des instances de classe, et attribuez à chacun une étiquette de type ainsi qu’une branche de reviver correspondante. Vérifiez ensuite que chaque reviver déjà en place se termine par un return value de repli.

FAQ

structuredClone rend-il le reviver inutile ?

Non, car structuredClone produit une copie en mémoire et non une chaîne JSON : elle ne peut donc pas être écrite dans localStorage ni dans le corps d'une requête. Il préserve bien Date, Map, Set et les références circulaires, mais il lève une DataCloneError sur les fonctions et ne copie pas la chaîne de prototypes : une instance de classe arrive donc toujours sous forme d'objet simple, dépourvu de ses méthodes. Restaurer des instances à partir de texte nécessite toujours un reviver.

Faut-il utiliser toJSON ou une fonction replacer ?

Utilisez toJSON lorsque le type est propriétaire de son format de transport : la méthode vit sur la classe, donc chaque sérialisation de cette valeur émet la même forme sans que l'appelant ait quoi que ce soit à faire. Utilisez un replacer lorsque la règle appartient à un point d'appel précis, comme supprimer un champ password ou convertir une Map issue d'une bibliothèque que vous ne contrôlez pas. toJSON s'exécute en premier, si bien que le replacer reçoit ce que toJSON a renvoyé.

Le reviver s'exécute-t-il aussi sur les éléments d'un tableau ?

Oui. Les index de tableau sont transmis au reviver sous forme de chaînes : le premier élément arrive donc avec la clé '0', puis le tableau lui-même est transmis à son tour sous sa propre clé. Renvoyer undefined pour un élément supprime cet élément au lieu de décaler les suivants, ce qui laisse un trou alors que la longueur du tableau reste inchangée. Les revivers appliqués aux tableaux ont besoin du même return value de repli que ceux appliqués aux objets.

Comment typer un reviver de JSON.parse en TypeScript ?

JSON.parse renvoie any en TypeScript, quel que soit le comportement du reviver. La bibliothèque standard déclare le reviver avec une clé de type string, une valeur de type any et un type de retour any : un reviver qui reconstruit des Date ou des instances de classe n'apporte donc aucune information supplémentaire au compilateur. Annotez le résultat avec un type explicite au point d'appel, ou faites passer la valeur analysée par un validateur de schéma avant de vous fier à sa forme.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.