2.2 · Objets de données

Les diapositives de la séance 2

  • décrire la forme d’un objet avec un type littéral, un type ou une interface
  • choisir entre type et interface, et utiliser ? et readonly à bon escient
  • expliquer le typage structurel, et pourquoi une propriété en trop est refusée
  • copier un objet en modifiant une propriété, sans toucher à l’original
  • manipuler des objets imbriqués : un objet dans un objet, un tableau dans un objet, des objets dans un tableau

Les types de base et l’inférence du chapitre 1.1 · Hello world, et les fonctions du chapitre 2.1 · Fonctions fléchées.

Presque toutes les données d’une application sont des objets : un livre dans un catalogue, un utilisateur, une mesure renvoyée par une API. Ce chapitre apprend à les décrire et à les manipuler comme des données. Le chapitre suivant, 2.3 · Tableaux, travaille directement sur des tableaux de ces objets. Les classes, qui ajoutent le comportement aux données, occupent toute la séance 3.

Le code complet du chapitre : télécharger app.ts. Exécutez-le avec npx tsx app.ts.

Un objet, c’est quoi en TypeScript ?

Un objet est un ensemble de propriétés : des données nommées. On peut en créer un directement, sans rien déclarer au préalable :

const alice = {
  nom: "Alice",
  age: 25,
};

console.log(alice.nom);        // Alice
console.log(alice["age"]);     // 25 (équivalent, mais moins lisible)

TypeScript a inféré le type de alice : { nom: string; age: number }. Ce type s’appelle un type littéral d’objet. Vous pouvez aussi l’écrire à la main :

const bob: { nom: string; age: number } = {
  nom: "Bob",
  age: 30,
};

Illisible dès que l’objet grossit, et impossible à réutiliser ailleurs. D’où les deux outils suivants.

Nommer une forme : type et interface

L’alias type

type Personne = {
  nom: string;
  age: number;
};

const carol: Personne = { nom: "Carol", age: 41 };

type donne simplement un nom à un type existant. Il fonctionne sur n’importe quoi, pas seulement sur des objets :

type Identifiant = string | number;          // union
type Genre = "roman" | "essai" | "bd";       // types littéraux
type Coordonnees = [number, number];         // tuple

L’interface

interface Personne {
  nom: string;
  age: number;
}

Une interface décrit la forme d’un objet, et rien d’autre. En contrepartie, elle sait faire deux choses que type fait moins bien : être étendue, et être implémentée par une classe (vous le verrez au chapitre 3.2).

interface Employe extends Personne {
  salaire: number;
}

const dave: Employe = { nom: "Dave", age: 35, salaire: 42000 };

type ou interface ?

interface type
Décrire un objet oui oui
Décrire une union, un tuple, un littéral non oui
Être implémentée par une classe oui oui, si c’est un objet
Héritage extends intersection &
Deux déclarations du même nom fusionnent erreur

Règle simple, et suffisante pour ce cours : interface pour décrire un objet ou un contrat de classe, type pour tout le reste. C’est la convention majoritaire dans l’écosystème TypeScript.

Propriétés optionnelles et readonly

interface Produit {
  readonly reference: string;   // fixée à la création, jamais modifiée ensuite
  nom: string;
  promotion?: number;           // le ? signifie : peut être absente
}

const clavier: Produit = { reference: "KB-01", nom: "Clavier" };  // OK, pas de promotion

clavier.nom = "Clavier mécanique";   // OK
clavier.reference = "KB-02";         // ERREUR : Cannot assign to 'reference'

Une propriété marquée ? a le type number | undefined. TypeScript vous oblige donc à traiter le cas absent :

// ERREUR : 'clavier.promotion' is possibly 'undefined'
const remise = clavier.promotion * 0.5;

// Correct : on vérifie d'abord
const remiseSure = clavier.promotion !== undefined ? clavier.promotion * 0.5 : 0;

// Ou avec l'opérateur ?? qui fournit une valeur de repli
const remiseCourte = (clavier.promotion ?? 0) * 0.5;

const et readonly ne protègent pas la même chose. const clavier interdit de remplacer l’objet (clavier = autreProduit est refusé), mais laisse modifier ses propriétés. readonly reference protège une propriété, quel que soit le mot-clé de la variable.

Le typage structurel

TypeScript vérifie les types par leur forme (structural typing), pas par leur nom. Un objet qui a nom et age est une Personne, même s’il n’a jamais entendu parler de Personne :

function presenter(p: Personne): string {
  return `${p.nom}, ${p.age} ans`;
}

const etudiant = { nom: "Eve", age: 22, ecole: "Télécom Paris" };
console.log(presenter(etudiant));   // accepté : etudiant a au moins nom et age

etudiant a une propriété de plus, et c’est accepté : la fonction n’utilise que nom et age, qui sont bien là. En revanche, affecter un littéral portant une propriété en trop déclenche une erreur spécifique :

const eve: Personne = { nom: "Eve", age: 22, ville: "Paris" };
// ERREUR : Object literal may only specify known properties

C’est volontaire : sur un littéral écrit à la main, une propriété inconnue est presque toujours une faute de frappe (nom / nomm). Le compilateur vous la signale au seul endroit où elle ne peut pas être voulue.

Copier un objet en modifiant une propriété

Une variable ne contient pas un objet : elle le désigne. L’affectation ne copie donc rien, elle donne un second nom au même objet :

const livre = { titre: "Dune", prix: 12.5 };
const alias = livre;

alias.prix = 9.9;
console.log(livre.prix);   // 9.9 : c'est le même objet

Pour obtenir un nouvel objet, on utilise la décomposition ..., qui recopie toutes les propriétés, puis on écrit celles qui changent :

const original = { titre: "Dune", prix: 12.5 };
const enPromo = { ...original, prix: 9.9 };

console.log(original.prix);        // 12.5 : intact
console.log(enPromo.prix);         // 9.9
console.log(enPromo === original); // false : deux objets distincts

C’est la façon standard de « modifier » une donnée sans toucher à l’original, et la seule possible sur une propriété readonly.

L’ordre compte : les propriétés sont écrites de gauche à droite, la dernière gagne. { prix: 9.9, ...original } recopie original après le nouveau prix, qui est donc écrasé : le résultat coûte toujours 12,5.

Objets imbriqués

Une propriété peut contenir n’importe quoi : un autre objet, un tableau, un tableau d’objets. Nommez chaque forme intermédiaire par sa propre interface, le code reste lisible :

app.ts
interface Adresse {
  ville: string;
  codePostal: string;
}

interface Livre {
  titre: string;
  prix: number;
}

interface Client {
  nom: string;
  adresse: Adresse;   // un objet dans un objet
  achats: Livre[];    // un tableau d'objets dans un objet
}

const client: Client = {
  nom: "Bob",
  adresse: { ville: "Paris", codePostal: "75013" },
  achats: [
    { titre: "Dune", prix: 12.5 },
    { titre: "Maus", prix: 32 },
  ],
};

console.log(client.adresse.ville);     // Paris
console.log(client.achats.length);     // 2
console.log(client.achats[1].titre);   // Maus

On lit un chemin d’accès de gauche à droite : client.achats[1].titre se lit « le client, ses achats, le deuxième, son titre ».

Le cas le plus fréquent est l’inverse : des objets dans un tableau. Un catalogue, une liste d’utilisateurs, une réponse d’API sont presque toujours de la forme Livre[]. C’est exactement ce que le chapitre suivant apprend à parcourir, filtrer et résumer.

const catalogue: Livre[] = [
  { titre: "Dune", prix: 12.5 },
  { titre: "Maus", prix: 32 },
];

for (const l of catalogue) {
  console.log(`${l.titre} : ${l.prix} €`);
}

La décomposition { ...client } est une copie superficielle : elle recopie le premier niveau, mais adresse et achats restent partagés entre l’original et la copie. Pour changer la ville sans toucher à l’original, il faut recopier aussi le niveau imbriqué :

const demenage: Client = {
  ...client,
  adresse: { ...client.adresse, ville: "Lyon" },
};
console.log(client.adresse.ville);     // Paris : intact

Vous venez du C ?

Concept C Équivalent TypeScript
struct Personne { ... }; interface Personne { ... }
struct Personne p = {"Alice", 25}; const p: Personne = { nom: "Alice", age: 25 };
p.nom p.nom
struct Personne q = p; (copie les champs) const q = { ...p };
struct Personne *r = &p; (même objet) const r = p;
const struct Personne p propriétés readonly

La différence qui surprend le plus : en C, affecter une struct copie ses champs. En TypeScript, affecter un objet ne copie rien, c’est l’équivalent d’un pointeur, sans l’étoile. Dès que vous voulez une copie, écrivez-la avec ....

Exercices

Exercice 2.4 : Décrire une fiche de film

essentiel

Dans un fichier film.ts :

  1. déclarez un type Genre qui n’accepte que "drame", "comedie" ou "animation" ;
  2. déclarez une interface Film avec titre: string, realisateur: string, duree: number (en minutes) et genre: Genre ;
  3. créez deux objets Film et affichez pour chacun Titre, de Réalisateur (125 min) ;
  4. provoquez volontairement quatre erreurs, une à la fois, et notez le message de tsc : un film sans duree ; une duree écrite "120" ; un film avec une propriété note en plus ; un genre écrit "comédie", avec l’accent.

Réussite : les deux fiches s’affichent, et pour chaque message vous savez dire ce que vous avez fourni et ce qui était attendu.

film.ts
type Genre = "drame" | "comedie" | "animation";

interface Film {
  titre: string;
  realisateur: string;
  duree: number;   // en minutes
  genre: Genre;
}

const film1: Film = {
  titre: "Le Voyage de Chihiro",
  realisateur: "Hayao Miyazaki",
  duree: 125,
  genre: "animation",
};

const film2: Film = {
  titre: "Intouchables",
  realisateur: "Olivier Nakache et Éric Toledano",
  duree: 112,
  genre: "comedie",
};

function afficher(film: Film): void {
  console.log(`${film.titre}, de ${film.realisateur} (${film.duree} min)`);
}

afficher(film1);   // Le Voyage de Chihiro, de Hayao Miyazaki (125 min)
afficher(film2);   // Intouchables, de Olivier Nakache et Éric Toledano (112 min)

Les quatre erreurs volontaires :

const f1: Film = { titre: "A", realisateur: "B", genre: "drame" };
// TS2741: Property 'duree' is missing in type '{ titre: string; realisateur: string;
//         genre: "drame"; }' but required in type 'Film'.

const f2: Film = { titre: "A", realisateur: "B", duree: "120", genre: "drame" };
// TS2322: Type 'string' is not assignable to type 'number'.

const f3: Film = { titre: "A", realisateur: "B", duree: 120, genre: "drame", note: 4 };
// TS2353: Object literal may only specify known properties, and 'note' does not
//         exist in type 'Film'.

const f4: Film = { titre: "A", realisateur: "B", duree: 120, genre: "comédie" };
// TS2820: Type '"comédie"' is not assignable to type 'Genre'. Did you mean '"comedie"'?

Le point clé : chaque message suit le même schéma, « ce que vous avez fourni » face à « ce que l’interface exige ». La propriété manquante est signalée avec son nom ; la mauvaise valeur, avec les deux types en présence ; la propriété en trop, parce qu’il s’agit d’un littéral écrit à la main (voir le typage structurel).

La quatrième erreur est la plus précieuse. Avec genre: string, la faute d’accent passerait inaperçue et un filtre sur "comedie" ne trouverait jamais ce film. Le type littéral transforme une faute de frappe, invisible à l’exécution, en erreur de compilation, et le compilateur va jusqu’à proposer la bonne orthographe. L’éditeur, lui, vous propose les trois valeurs permises dès que vous ouvrez les guillemets.

Exercice 2.5 : Propriété optionnelle et readonly

entraînement

Partez de cette interface :

interface Livre {
  readonly isbn: string;
  titre: string;
  auteur: string;
  pages: number;
  traducteur?: string;
}
  1. Créez deux livres, l’un traduit, l’autre non.
  2. Écrivez une fonction fiche(livre: Livre): string qui renvoie Dune, Frank Herbert, 704 p., traduit par Michel Demuth, et seulement Maus, Art Spiegelman, 296 p. quand il n’y a pas de traducteur.
  3. Essayez de modifier le titre d’un livre, puis son isbn. Lequel est refusé, avec quel message ?
  4. Écrivez livre.traducteur.toUpperCase() sans aucun test. Que dit tsc ?

Réussite : aucune fiche n’affiche undefined, et vous savez citer les deux messages d’erreur.

livre.ts
interface Livre {
  readonly isbn: string;
  titre: string;
  auteur: string;
  pages: number;
  traducteur?: string;
}

const dune: Livre = {
  isbn: "978-2-266-32048-8",
  titre: "Dune",
  auteur: "Frank Herbert",
  pages: 704,
  traducteur: "Michel Demuth",
};

const maus: Livre = {
  isbn: "978-2-08-081148-6",
  titre: "Maus",
  auteur: "Art Spiegelman",
  pages: 296,
};

function fiche(livre: Livre): string {
  const base = `${livre.titre}, ${livre.auteur}, ${livre.pages} p.`;
  if (livre.traducteur !== undefined) {
    return `${base}, traduit par ${livre.traducteur}`;
  }
  return base;
}

console.log(fiche(dune));   // Dune, Frank Herbert, 704 p., traduit par Michel Demuth
console.log(fiche(maus));   // Maus, Art Spiegelman, 296 p.

dune.titre = "Dune (édition poche)";   // accepté : titre n'est pas readonly

Les deux tentatives refusées :

dune.isbn = "000";
// TS2540: Cannot assign to 'isbn' because it is a read-only property.

console.log(maus.traducteur.toUpperCase());
// TS18048: 'maus.traducteur' is possibly 'undefined'.

Le point clé : traducteur?: string a en réalité le type string | undefined. C’est ce qui oblige à tester avant d’appeler une méthode dessus. Après le if, TypeScript sait que la valeur n’est plus undefined : on dit qu’il a affiné le type.

L’erreur classique est d’écrire directement :

return `${livre.titre}, ${livre.auteur}, ${livre.pages} p., traduit par ${livre.traducteur}`;

qui compile parfaitement et affiche traduit par undefined pour maus : dans un gabarit de chaîne, undefined est converti en texte au lieu de provoquer une erreur. Le compilateur ne vous protège que si vous utilisez la valeur, pas si vous l’affichez.

Remarquez enfin que dune est déclaré avec const, et que modifier son titre est pourtant accepté : const protège la variable, readonly protège la propriété.

Exercice 2.6 : Une remise sans effet de bord

entraînement transfert

interface LivreEnVente {
  readonly isbn: string;
  titre: string;
  prix: number;
}
  1. Écrivez appliquerRemise(livre: LivreEnVente, taux: number): LivreEnVente, qui renvoie un nouvel objet dont le prix est réduit de taux (0.2 pour 20 %), arrondi au centime. L’original ne doit pas changer.
  2. Vérifiez-le : affichez le prix de l’original et celui du résultat, et comparez les deux objets avec ===.
  3. Écrivez une version fautive, remiseFautive, qui modifie livre.prix puis renvoie livre. Qu’affichent les mêmes vérifications ?

Réussite : après l’appel, le prix de l’original est inchangé et === renvoie false.

Math.round(x * 100) / 100 arrondit au centime.

remise.ts
interface LivreEnVente {
  readonly isbn: string;
  titre: string;
  prix: number;
}

function appliquerRemise(livre: LivreEnVente, taux: number): LivreEnVente {
  const nouveauPrix = Math.round(livre.prix * (1 - taux) * 100) / 100;
  return { ...livre, prix: nouveauPrix };
}

function remiseFautive(livre: LivreEnVente, taux: number): LivreEnVente {
  livre.prix = Math.round(livre.prix * (1 - taux) * 100) / 100;   // modifie l'original
  return livre;
}

const dune: LivreEnVente = { isbn: "978-2-266-32048-8", titre: "Dune", prix: 12.5 };
const promo = appliquerRemise(dune, 0.2);
console.log(dune.prix, promo.prix);   // 12.5 10
console.log(dune === promo);          // false

const maus: LivreEnVente = { isbn: "978-2-08-081148-6", titre: "Maus", prix: 32 };
const fausse = remiseFautive(maus, 0.2);
console.log(maus.prix, fausse.prix);  // 25.6 25.6
console.log(maus === fausse);         // true

Le point clé : les deux fonctions ont exactement la même signature, et le compilateur accepte les deux. Seule la vérification montre la différence : remiseFautive renvoie le même objet, dont elle a changé le prix. Si ce livre figure dans un catalogue, son prix vient de baisser partout, pour tous ceux qui le consultent, alors qu’on voulait seulement calculer un prix promotionnel.

Une fonction qui renvoie un résultat sans rien modifier d’autre est dite sans effet de bord. C’est ce qu’on attend de appliquerRemise, et c’est exactement ainsi que fonctionnent map et filter au chapitre suivant.

L’erreur classique : écrire { prix: nouveauPrix, ...livre }. Tout compile, mais la décomposition, placée en second, écrase le nouveau prix par l’ancien.

Pour aller plus loin : typez le paramètre livre: Readonly<LivreEnVente>. Readonly<T> rend toutes les propriétés readonly, et remiseFautive ne compile plus : Cannot assign to 'prix' because it is a read-only property. L’intention « je ne modifie pas ce paramètre » est alors vérifiée par le compilateur.

QCM

1. Que vaut a.prix à la fin ?

const a = { titre: "Dune", prix: 10 };
const b = a;
b.prix = 20;

A. 10 B. 20 C. undefined D. Une erreur de compilation, car a est const.

B. b = a ne copie pas l’objet : a et b désignent le même. Et const interdit de remplacer a, pas de modifier ses propriétés.

2. Que vaut c.prix ?

const livre = { titre: "Dune", prix: 12 };
const c = { prix: 5, ...livre };

A. 5 B. 12 C. 17 D. undefined

B. La décomposition, écrite après, recopie prix: 12 par-dessus prix: 5. Pour remplacer une propriété, elle doit venir après ...livre.

3. Avec interface Personne { nom: string; age: number }, laquelle de ces lignes compile ?

A. const p: Personne = { nom: "Eve", age: 22, ville: "Paris" }; B. const brut = { nom: "Eve", age: 22, ville: "Paris" }; const p: Personne = brut; C. const p: Personne = { nom: "Eve" }; D. const p: Personne = { nom: "Eve", age: "22" };

B. brut a au moins nom et age : par typage structurel, c’est une Personne. A est refusée parce que c’est un littéral avec une propriété en trop, C parce qu’il manque age, D parce que "22" est une chaîne.

À retenir

  • interface pour décrire un objet, type pour tout le reste (unions, littéraux, tuples)
  • prop?: T a le type T | undefined : testez avant d’utiliser
  • readonly protège une propriété, const protège une variable
  • Seule la forme compte ; une propriété en trop n’est refusée que dans un littéral
  • b = a ne copie rien ; { ...a, prix: 10 } fabrique un nouvel objet
  • La copie par ... est superficielle : les objets imbriqués restent partagés