5.1 · JSON

Les diapositives de la séance 5

  • expliquer ce qu’est le format JSON et ce qu’il sait (ou ne sait pas) transporter
  • sérialiser un objet avec JSON.stringify et désérialiser une chaîne avec JSON.parse
  • comprendre pourquoi JSON.parse renvoie any et en quoi c’est dangereux
  • écrire une fonction de validation (type guard) qui transforme de l’unknown en type sûr
  • expliquer pourquoi un objet désérialisé n’a pas de méthodes, et reconstruire de vraies instances

Les interfaces du chapitre 2.2 · Objets de données, les classes du chapitre 3.1 · Classes et encapsulation, et la notion d’unknown vue au chapitre 1.1 · Hello world.

Lisez d’abord la définition officielle du format sur json.org : elle tient sur une page.

Code du chapitre : télécharger app.ts · télécharger deezerOffline.ts

Le format

JSON signifie JavaScript Object Notation. C’est un format texte d’échange de données, indépendant de tout langage, que l’on retrouve dans quasiment toutes les API web.

{
  "id": "1042",
  "name": "Joe",
  "age": 27,
  "scores": [31.4, 29.9, 35.7],
  "actif": true,
  "surnom": null
}

Six types de valeurs, et pas un de plus : chaîne, nombre, booléen, null, tableau, objet. Trois règles qui font trébucher :

  • les clés sont toujours entre guillemets doubles ;
  • pas de virgule après le dernier élément ;
  • pas de commentaires.
NoteJSON n’est pas du JavaScript

La syntaxe y ressemble, mais JSON est un format de données inertes. Pas de fonctions, pas de méthodes, pas de Date, pas de Map, pas de Set, pas de référence circulaire. Ce point est la source de la moitié des bugs du chapitre.

Deux opérations symétriques… en apparence

Opération Nom Sens
JSON.stringify(obj) sérialisation objet TypeScript → chaîne
JSON.parse(str) désérialisation chaîne → valeur JavaScript
const point = { x: 3, y: 4 };

const texte: string = JSON.stringify(point);
console.log(texte);          // {"x":3,"y":4}

const relu = JSON.parse(texte);
console.log(relu.x + relu.y);   // 7

L’asymétrie est fondamentale, et c’est le cœur du chapitre :

  • quand vous sérialisez, vous partez d’une valeur dont TypeScript connaît le type. Aucun risque.
  • quand vous désérialisez, vous partez d’une chaîne venue de l’extérieur : un fichier, un serveur, un formulaire. TypeScript ne sait rien de son contenu.

Sérialiser

JSON.stringify accepte un troisième paramètre d’indentation, très utile pour déboguer :

console.log(JSON.stringify(point, null, 2));
{
  "x": 3,
  "y": 4
}

Ce qui disparaît silencieusement à la sérialisation :

const objet = {
  nom: "Alice",
  saluer: () => console.log("hello"),   // fonction : ignorée
  absent: undefined,                    // undefined : ignoré
  date: new Date(),                     // converti en chaîne ISO
};

console.log(JSON.stringify(objet));
// {"nom":"Alice","date":"2026-09-11T10:00:00.000Z"}

Aucune erreur, aucun avertissement. Les propriétés ont juste disparu.

Désérialiser : le vrai sujet

Le piège de any

Regardez la signature réelle de la fonction :

JSON.parse(text: string): any

Elle renvoie any. C’est-à-dire : TypeScript renonce à vérifier quoi que ce soit sur la valeur obtenue.

const data = JSON.parse('{"name":"Joe"}');
console.log(data.nom.toUpperCase());   // aucune erreur de compilation...
// TypeError: Cannot read properties of undefined (reading 'toUpperCase')

JSON.parse renvoie any, et any désactive le typage. Le code ci-dessus compile parfaitement et plante à l’exécution. C’est exactement le scénario que TypeScript est censé vous éviter, sauf qu’ici, vous avez ouvert la porte vous-même.

La solution courante : annoter avec une interface

app.ts
interface UserInterface {
  id: string;
  name: string;
  age: number;
  scores: number[];
}

const data = `[
  { "id": "1042", "name": "Joe",   "age": 27, "scores": [31.4, 29.9, 35.7] },
  { "id": "1071", "name": "Sarah", "age": 29, "scores": [25.0, 27.1] }
]`;

// On annote la variable : TypeScript accepte, car any est assignable à tout
const users: UserInterface[] = JSON.parse(data);

users.forEach((u) => {
  console.log(`id: ${u.id}, name: ${u.name}, age: ${u.age}`);
});

À partir de cette ligne, vous bénéficiez de l’autocomplétion et de la vérification des types. C’est ce que fait 90 % du code TypeScript en production, et c’est ce que nous ferons dans la suite du cours.

Mais comprenez bien ce que vous venez de faire : vous avez affirmé que le JSON avait cette forme. TypeScript vous a cru sur parole. Si le serveur renvoie autre chose demain, rien ne vous préviendra à la compilation.

La solution rigoureuse : unknown + validation

Le réflexe correct est celui du chapitre 1.1 : quand une donnée vient de l’extérieur, on la type unknown et on prouve sa forme avant de l’utiliser.

interface ServerData {
  number: number;
  string: string;
  numbers: number[];
}

// Un "type guard" : le type de retour `value is ServerData` informe le compilateur
function isServerData(value: unknown): value is ServerData {
  return (
    typeof value === "object" &&
    value !== null &&
    "number" in value && typeof value.number === "number" &&
    "string" in value && typeof value.string === "string" &&
    "numbers" in value &&
      Array.isArray(value.numbers) &&
      value.numbers.every((item) => typeof item === "number")
  );
}

const parsed: unknown = JSON.parse(json);   // on force unknown, pas any

if (isServerData(parsed)) {
  console.log(parsed.number);   // ici, et seulement ici, le type est garanti
} else {
  console.error("Données inattendues");
}

La construction value is ServerData s’appelle une prédicat de type. Elle dit au compilateur : « si cette fonction renvoie true, alors tu peux considérer que la valeur a ce type ». C’est la seule façon propre de passer d’unknown à un type précis.

Approche Sûreté à l’exécution Effort Quand l’utiliser
JSON.parse(x) sans rien aucune nul jamais
const u: User = JSON.parse(x) aucune faible TP, source de confiance
unknown + type guard réelle moyen API externe, production
NoteEt en vrai ?

En production on utilise une bibliothèque de validation (Zod, Valibot, io-ts) qui génère le type et le validateur à partir d’une seule déclaration. Le principe reste celui ci-dessus ; l’outil ne fait qu’éviter d’écrire les typeof à la main.

Le piège des méthodes perdues

NoteRappel POO

Une instance est un objet fabriqué par new : elle porte les données et les méthodes de sa classe. Un objet littéral ne porte que des données. Voir 3.1 · Les classes et 3.1 · Classe ou objet littéral ?.

JSON.parse restitue des données, jamais des instances de classe.

app.ts
class User implements UserInterface {
  constructor(
    public id: string,
    public name: string,
    public age: number,
    public scores: number[]
  ) {}

  getMaxScore(): number {
    return Math.max(...this.scores);
  }
}

const u1 = new User("456", "Eya", 23, [2.4, 34]);
const json = JSON.stringify([u1]);
console.log(json);   // [{"id":"456","name":"Eya","age":23,"scores":[2.4,34]}]

La méthode getMaxScore n’apparaît pas dans le JSON : normal, JSON ne transporte pas de code. Le problème surgit au retour :

interface IUser {
  id: string;
  scores: number[];
  getMaxScore(): number;   // déclarée dans l'interface
}

const relus: IUser[] = JSON.parse(json);
relus[0].getMaxScore();
// Compile sans erreur. À l'exécution :
// TypeError: relus[0].getMaxScore is not a function

Une annotation de type ne crée rien à l’exécution. Vous avez promis au compilateur que ces objets auraient une méthode getMaxScore ; il vous a cru. Le moteur JavaScript, lui, n’a reçu que des propriétés.

Ne mettez jamais de méthode dans une interface destinée à typer un résultat de JSON.parse. Séparez les deux rôles : une interface de données pour le transport, une classe pour le comportement.

La solution consiste à reconstruire de vraies instances :

interface UserData {          // uniquement des données
  id: string;
  name: string;
  age: number;
  scores: number[];
}

function toUsers(data: UserData[]): User[] {
  return data.map((d) => new User(d.id, d.name, d.age, d.scores));
}

const users: User[] = toUsers(JSON.parse(json));
console.log(users[1].name, users[1].getMaxScore());   // fonctionne

Un cas réel : l’API Deezer

deezerOffline.ts contient une réponse authentique de l’API Deezer, enregistrée pour travailler hors ligne. L’interface a été générée automatiquement avec quicktype.io :

deezerOffline.ts
interface GenreInterface {
  id: number;
  name: string;
  picture: string;
  picture_small: string;
  picture_medium: string;
  picture_big: string;
  picture_xl: string;
  type: string;
}

// L'API enveloppe toujours le tableau dans un objet { data: [...] }
interface GenreAPI {
  data: GenreInterface[];
}

const genresRaw: GenreAPI = JSON.parse(response);
const genres: GenreInterface[] = genresRaw.data;

for (const genre of genres) {
  console.log(genre.name);
}

Deux réflexes à retenir : coller un échantillon de réponse dans quicktype pour obtenir l’interface, et repérer l’enveloppe (ici { data: [...] }) avant d’écrire quoi que ce soit. Le chapitre 6.2 · fetch reprendra ce code en ligne.

Faire passer des données du serveur au navigateur

JSON ne sert pas qu’aux API distantes. C’est aussi la façon la plus simple de transmettre des données du code serveur Astro vers un script exécuté dans le navigateur :

  1. le frontmatter Astro (entre les ---) s’exécute côté serveur ;
  2. on sérialise l’objet dans un attribut data-* du HTML produit ;
  3. côté client, on relit l’attribut via dataset, puis JSON.parse.
index.astro
---
const serverData = { number: 42, string: "bonjour", numbers: [10, 20, 30] };
---

<div id="server-data" data-payload={JSON.stringify(serverData)}></div>
<ul id="result"></ul>

<script>
  const dataElement = document.getElementById("server-data");
  if (dataElement instanceof HTMLElement) {
    const parsed: unknown = JSON.parse(dataElement.dataset.payload ?? "{}");
    // ... validation avec isServerData(), puis affichage
  }
</script>

Le navigateur ne voit jamais la variable serverData : il ne reçoit que le HTML produit par le serveur. Technique précieuse quand on fait un fetch côté serveur et qu’on veut réutiliser les mêmes données côté client sans refaire une requête.

Exercices

Exercice 5.1 : Aller-retour

essentiel

Dans exercice07a.ts :

  1. créez un objet config avec au moins une chaîne, un nombre, un booléen, un tableau et un objet imbriqué ;
  2. sérialisez-le avec indentation à 2 espaces et affichez le résultat ;
  3. désérialisez la chaîne obtenue et vérifiez que les valeurs sont identiques ;
  4. ajoutez maintenant à config une propriété demarrer: () => console.log("go") et une propriété vide: undefined, puis re-sérialisez.

Question : que reste-t-il des deux dernières propriétés dans la chaîne ? Pourquoi aucune erreur n’est-elle signalée ?

const config = {
  nom: "mon-app",
  version: 3,
  debug: true,
  modules: ["auth", "logs"],
  serveur: { hote: "localhost", port: 8080 },
};

// 2. Sérialisation lisible
const texte: string = JSON.stringify(config, null, 2);
console.log(texte);

// 3. Aller-retour
const relu = JSON.parse(texte);
console.log(relu.nom === config.nom);                       // true
console.log(relu.serveur.port === config.serveur.port);     // true
console.log(relu.modules.length === config.modules.length); // true
console.log(relu === config);                               // false : nouvel objet

// 4. On ajoute une fonction et une propriété undefined
const config2 = {
  ...config,
  demarrer: () => console.log("go"),
  vide: undefined,
};

console.log(JSON.stringify(config2));
// {"nom":"mon-app","version":3,"debug":true,"modules":["auth","logs"],
//  "serveur":{"hote":"localhost","port":8080}}

Réponse à la question. Il ne reste rien : demarrer et vide ont purement et simplement disparu de la chaîne. Aucune erreur n’est signalée parce qu’il ne s’agit pas d’une erreur, mais du comportement spécifié de JSON.stringify. Le format JSON ne connaît que six types de valeurs : chaîne, nombre, booléen, null, tableau, objet. Une fonction et undefined n’en font pas partie : la spécification demande d’omettre la propriété, pas de refuser la conversion.

Le point clé. C’est une perte silencieuse, et c’est exactement ce qui la rend dangereuse. Vous ne découvrirez le problème qu’au moment où vous relirez la donnée, souvent ailleurs dans le programme et souvent bien plus tard.

Trois conséquences à connaître :

console.log(JSON.stringify([1, undefined, 3]));
// [1,null,3]   : dans un TABLEAU, on ne peut pas omettre : undefined devient null

console.log(JSON.stringify({ d: new Date("2026-01-01") }));
// {"d":"2026-01-01T00:00:00.000Z"}   : une Date survit... en tant que CHAÎNE

const d = JSON.parse('{"d":"2026-01-01T00:00:00.000Z"}').d;
console.log(typeof d);   // "string" : ce n'est plus une Date, d.getFullYear() plantera

Notez enfin le type de relu à l’étape 3 : any. Les comparaisons ci-dessus compilent, mais relu.serveur.portt compilerait aussi et vaudrait undefined. C’est tout le sujet de l’exercice suivant.

Exercice 5.2 : Valider avant d’utiliser

essentiel

On reçoit cette chaîne, volontairement incorrecte :

const brut = '{"id": 42, "name": "Joe", "age": "vingt-sept"}';
  1. déclarez interface Utilisateur { id: string; name: string; age: number } ;
  2. écrivez const u: Utilisateur = JSON.parse(brut); puis affichez u.age * 2. Que compile-t-il ? Qu’affiche-t-il ?
  3. réécrivez proprement : const brutParse: unknown = JSON.parse(brut); puis un type guard estUtilisateur(v: unknown): v is Utilisateur ;
  4. affichez "données valides" ou "données invalides" selon le cas.

Critère de réussite : la version 3 détecte les deux anomalies (id est un nombre, age est une chaîne) et ne lit aucune propriété avant de les avoir vérifiées.

Étapes 1 et 2, la version qui ment :

const brut = '{"id": 42, "name": "Joe", "age": "vingt-sept"}';

interface Utilisateur {
  id: string;
  name: string;
  age: number;
}

const u: Utilisateur = JSON.parse(brut);
console.log(u.age * 2);   // NaN

Cela compile : JSON.parse renvoie any, et any est assignable à n’importe quel type. TypeScript ne vérifie donc absolument rien. Et cela affiche NaN : "vingt-sept" * 2 ne lève pas d’exception en JavaScript, il produit NaN. C’est le scénario le plus coûteux à déboguer : ni erreur de compilation, ni erreur d’exécution, juste un résultat faux qui se propage.

Étapes 3 et 4, la version correcte :

function estUtilisateur(v: unknown): v is Utilisateur {
  return (
    typeof v === "object" &&
    v !== null &&
    "id" in v && typeof v.id === "string" &&
    "name" in v && typeof v.name === "string" &&
    "age" in v && typeof v.age === "number"
  );
}

const brutParse: unknown = JSON.parse(brut);

if (estUtilisateur(brutParse)) {
  console.log("données valides");
  console.log(brutParse.age * 2);   // ici seulement, age est garanti number
} else {
  console.log("données invalides");
}

Sortie : données invalides. Les deux anomalies sont détectées : typeof 42 === "string" est faux, typeof "vingt-sept" === "number" aussi. Une seule aurait suffi à faire basculer le &&.

Le point clé. C’est le type de retour v is Utilisateur qui fait tout le travail. Sans lui, la fonction renverrait un simple boolean et le compilateur refuserait brutParse.age dans le if, puisque brutParse resterait unknown. Le prédicat de type est le seul mécanisme qui permette de passer d’unknown à un type précis en l’ayant prouvé.

Deux pièges dans l’écriture du garde :

  • typeof null === "object" vaut true en JavaScript. Sans la ligne v !== null, un JSON valant null passerait le premier test et ferait planter les suivants ;
  • l’ordre des opérandes du && n’est pas décoratif. "id" in v doit précéder typeof v.id === "string" : c’est ce qui permet à TypeScript de réduire progressivement le type de v et d’accepter l’accès à v.id.

Variante courante : faire renvoyer au garde la raison de l’échec plutôt qu’un simple booléen, ce qui aide énormément au débogage. C’est exactement ce que fournissent les bibliothèques comme Zod, au prix d’une déclaration unique à la place du garde manuel.

Exercice 5.3 : Données contre comportement

entraînement transfert

Exercice de synthèse, à faire dans exercice07c.ts.

  1. déclarez interface IUser avec id: string, name: string, age: number, scores: number[] et une méthode getMaxScore(): number ;
  2. créez une classe User implements IUser qui ajoute getAverageScore(): number (absente de l’interface) ;
  3. créez trois instances et rangez-les dans un tableau users: IUser[] ;
  4. écrivez serializeUsers(users: IUser[]): string et affichez le résultat ;
  5. écrivez deserializeUsers(json: string): IUser[] et affichez le résultat ;
  6. appelez getAverageScore() sur un élément désérialisé → erreur de compilation. Expliquez pourquoi en une phrase ;
  7. appelez getMaxScore() sur ce même élément → compile, mais erreur à l’exécution. Expliquez pourquoi en une phrase ;
  8. écrivez toUsers(data: IUser[]): User[] qui reconstruit de vraies instances, puis affichez le nom, le score maximum et le score moyen du deuxième utilisateur ;
  9. corrigez enfin la conception : séparez IUser en une interface de données sans méthode et une classe User qui porte le comportement.

Critère de réussite : à l’étape 9, l’erreur de l’étape 7 devient impossible, parce que le type ne promet plus une méthode que les données ne peuvent pas contenir.

interface IUser {
  id: string;
  name: string;
  age: number;
  scores: number[];
  getMaxScore(): number;      // la méthode fautive : elle est ici pour la démonstration
}

class User implements IUser {
  constructor(
    public id: string,
    public name: string,
    public age: number,
    public scores: number[]
  ) {}

  getMaxScore(): number {
    return Math.max(...this.scores);
  }

  getAverageScore(): number {   // absente de IUser
    if (this.scores.length === 0) return 0;
    return this.scores.reduce((somme, s) => somme + s, 0) / this.scores.length;
  }
}

const users: IUser[] = [
  new User("u1", "Alice", 20, [12, 15, 18]),
  new User("u2", "Bob", 22, [10, 14, 17, 19]),
  new User("u3", "Charlie", 21, [11, 13, 16]),
];

function serializeUsers(liste: IUser[]): string {
  return JSON.stringify(liste);
}

function deserializeUsers(json: string): IUser[] {
  return JSON.parse(json);
}

function toUsers(data: IUser[]): User[] {
  return data.map((d) => new User(d.id, d.name, d.age, d.scores));
}

const json = serializeUsers(users);
console.log(json);
// [{"id":"u1","name":"Alice","age":20,"scores":[12,15,18]}, ... ]
// getMaxScore n'apparaît nulle part : JSON ne transporte pas de code

const relus = deserializeUsers(json);
console.log(relus);

// Étape 6 :
// relus[0].getAverageScore();
// ERREUR DE COMPILATION : Property 'getAverageScore' does not exist on type 'IUser'.

// Étape 7 :
// relus[0].getMaxScore();
// Compile. À l'exécution : TypeError: relus[0].getMaxScore is not a function

// Étape 8 : on reconstruit de vraies instances
const reconstruits: User[] = toUsers(relus);
console.log(reconstruits[1].name);              // Bob
console.log(reconstruits[1].getMaxScore());     // 19
console.log(reconstruits[1].getAverageScore()); // 15

Étape 6, en une phrase : le tableau est typé IUser[], et IUser ne déclare pas getAverageScore : le compilateur ne connaît que ce que le type promet, pas ce que les objets contiennent réellement.

Étape 7, en une phrase : IUser promet getMaxScore, donc le compilateur accepte, mais JSON.parse n’a fabriqué que des objets de données sans prototype User : la méthode n’existe pas à l’exécution.

Les deux étapes disent la même chose sous deux angles : une annotation de type ne crée rien. Elle décrit ce que vous affirmez, jamais ce qui existe.

// Une interface de DONNÉES : aucune méthode, c'est ce qui transite en JSON
interface UserData {
  id: string;
  name: string;
  age: number;
  scores: number[];
}

// Une classe qui porte le COMPORTEMENT
class User implements UserData {
  constructor(
    public id: string,
    public name: string,
    public age: number,
    public scores: number[]
  ) {}

  getMaxScore(): number {
    return Math.max(...this.scores);
  }

  getAverageScore(): number {
    if (this.scores.length === 0) return 0;
    return this.scores.reduce((somme, s) => somme + s, 0) / this.scores.length;
  }
}

function serializeUsers(liste: UserData[]): string {
  return JSON.stringify(liste);
}

function deserializeUsers(json: string): UserData[] {
  return JSON.parse(json);
}

function toUsers(data: UserData[]): User[] {
  return data.map((d) => new User(d.id, d.name, d.age, d.scores));
}

const donnees: UserData[] = deserializeUsers(json);

// donnees[0].getMaxScore();
// ERREUR DE COMPILATION : Property 'getMaxScore' does not exist on type 'UserData'.

const utilisateurs: User[] = toUsers(donnees);
console.log(utilisateurs[1].getMaxScore());       // 19
console.log(utilisateurs[1].getAverageScore());   // 15

Le point clé. L’erreur de l’étape 7 n’est pas corrigée, elle est rendue impossible à écrire. Puisque UserData ne déclare aucune méthode, le compilateur refuse d’appeler quoi que ce soit sur un objet fraîchement désérialisé ; le seul chemin qui reste est de passer par toUsers. Une erreur d’exécution vient d’être transformée en erreur de compilation, ce qui est toujours l’objectif.

Notez que class User implements UserData reste possible, et même souhaitable : cela garantit qu’un User est toujours sérialisable sans perte de données. La règle à retenir tient en une ligne : une interface qui traverse le réseau ne déclare que des données.

Limite honnête de ce corrigé : deserializeUsers continue de faire confiance au JSON reçu. Pour une vraie API, il faudrait y ajouter le prédicat de type de l’exercice 5.2.

QCM

1. Que produit ce code ?

const objet = { nom: "Alice", saluer: () => "bonjour", age: undefined };
console.log(JSON.stringify(objet));

A. {"nom":"Alice","saluer":"bonjour","age":null} B. {"nom":"Alice"} C. Une erreur à l’exécution. D. {"nom":"Alice","age":undefined}

B. Les fonctions et les valeurs undefined disparaissent sans avertissement : JSON ne transporte que des données.

2. Que se passe-t-il ?

const data = JSON.parse('{"name":"Joe"}');
console.log(data.nom.toUpperCase());

A. Une erreur de compilation : nom n’existe pas. B. Le code compile, puis plante à l’exécution. C. Il affiche JOE. D. Il affiche undefined.

B. JSON.parse renvoie any : le compilateur ne vérifie plus rien. data.nom vaut undefined, et undefined.toUpperCase() lève une TypeError.

3. Que se passe-t-il ?

interface IUser {
  name: string;
  saluer(): string;
}

const u: IUser = JSON.parse('{"name":"Joe"}');
u.saluer();

A. Une erreur de compilation : le JSON ne contient pas saluer. B. Le code compile, puis u.saluer is not a function à l’exécution. C. u.saluer() renvoie undefined. D. Tout fonctionne.

B. L’annotation promet une méthode, le compilateur vous croit, mais le JSON n’a apporté que des propriétés. Une interface qui type un JSON.parse ne déclare que des données.

À retenir

  • JSON.stringify sérialise du connu ; JSON.parse désérialise de l’inconnu
  • JSON.parse renvoie any : annoter n’est pas valider, c’est seulement promettre
  • Pour une source externe : const x: unknown = JSON.parse(...) puis un type guard v is T
  • JSON ne transporte que des données : ni fonctions, ni méthodes, ni Date, ni Map
  • Une interface de transport ne doit jamais déclarer de méthode
  • Pour retrouver le comportement, reconstruisez des instances avec map et new