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.stringifyet désérialiser une chaîne avecJSON.parse - comprendre pourquoi
JSON.parserenvoieanyet en quoi c’est dangereux - écrire une fonction de validation (type guard) qui transforme de l’
unknownen 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.
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); // 7L’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): anyElle 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 |
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
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 functionUne 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()); // fonctionneUn 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.
Exercices
Exercice 5.1 : Aller-retour
essentiel
Dans exercice07a.ts :
- créez un objet
configavec au moins une chaîne, un nombre, un booléen, un tableau et un objet imbriqué ; - sérialisez-le avec indentation à 2 espaces et affichez le résultat ;
- désérialisez la chaîne obtenue et vérifiez que les valeurs sont identiques ;
- ajoutez maintenant à
configune 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() planteraNotez 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"}';- déclarez
interface Utilisateur { id: string; name: string; age: number }; - écrivez
const u: Utilisateur = JSON.parse(brut);puis affichezu.age * 2. Que compile-t-il ? Qu’affiche-t-il ? - réécrivez proprement :
const brutParse: unknown = JSON.parse(brut);puis un type guardestUtilisateur(v: unknown): v is Utilisateur; - 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); // NaNCela 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"vauttrueen JavaScript. Sans la lignev !== null, un JSON valantnullpasserait le premier test et ferait planter les suivants ;- l’ordre des opérandes du
&&n’est pas décoratif."id" in vdoit précédertypeof v.id === "string": c’est ce qui permet à TypeScript de réduire progressivement le type devet 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.
- déclarez
interface IUseravecid: string,name: string,age: number,scores: number[]et une méthodegetMaxScore(): number; - créez une classe
User implements IUserqui ajoutegetAverageScore(): number(absente de l’interface) ; - créez trois instances et rangez-les dans un tableau
users: IUser[]; - écrivez
serializeUsers(users: IUser[]): stringet affichez le résultat ; - écrivez
deserializeUsers(json: string): IUser[]et affichez le résultat ; - appelez
getAverageScore()sur un élément désérialisé → erreur de compilation. Expliquez pourquoi en une phrase ; - appelez
getMaxScore()sur ce même élément → compile, mais erreur à l’exécution. Expliquez pourquoi en une phrase ; - é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 ; - corrigez enfin la conception : séparez
IUseren une interface de données sans méthode et une classeUserqui 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()); // 15Le 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.stringifysérialise du connu ;JSON.parsedésérialise de l’inconnuJSON.parserenvoieany: annoter n’est pas valider, c’est seulement promettre- Pour une source externe :
const x: unknown = JSON.parse(...)puis un type guardv is T - JSON ne transporte que des données : ni fonctions, ni méthodes, ni
Date, niMap - Une interface de transport ne doit jamais déclarer de méthode
- Pour retrouver le comportement, reconstruisez des instances avec
mapetnew