Retour au cours

Carnet, support de cours

POO, design patterns et frameworks

21 chapitres. Mis à jour le 23 octobre 2026.

Vous allez créer des créatures avec leur propre énergie, puis faire évoluer une application de réservation d'ateliers. Ces cas pratiques permettent de découvrir les classes, l'encapsulation, les interfaces et la collaboration entre objets, puis les design patterns et les frameworks.

Sommaire

Présentation

  1. Démarrer, lire une application qui fonctionneExécuter le point de départ, Suivre une réservation dans le code, Les règles de réservation, Le modèle en objets, Introduire une variation, Limites de la simulation
  2. Les bases de la programmation orientée objetObjets, classes et encapsulation, Modéliser un écosystème, Contrats et polymorphisme, Duck typing et typage structurel, Cohésion, couplage et responsabilités, Les principes SOLID, Composition et héritage
  3. Mise en situationSituation 1, des tarifs qui varient, Situation 2, une API externe et un enregistrement qui échoue, Situation 3, plusieurs réactions puis une interface, Exporter les réservations
  4. Ajouter un tarif avec Strategy
  5. Lire les places du partenaire avec Adapter
  6. Tracer une réservation avec Decorator
  7. Préparer la persistance avec Repository
  8. Faire réagir les vues avec ObserverLa même idée avec des fonctions
  9. Frameworks 1, qui appelle le code de l'applicationAvant, le terminal pose les questions dans son ordre, Après, le navigateur appelle vos fonctions, Préparer le projet, Écrire la première fonction du framework, Le trajet d’un clic, Nuance, une frontière qui dépend du point de vue
  10. Frameworks 2, un état observé et un rendu naïfL’état de l’interface, Un store est le sujet d’un Observer, Redessiner toute la page à chaque changement, Premier défaut, le champ perd le focus, Deuxième défaut, les écouteurs disparaissent, Troisième défaut, le texte saisi devient du HTML, Nuance, l’idée reste bonne
  11. Frameworks 3, composants en fonctions ou en classesUn composant regroupe ce qui change ensemble, Écrire le composant comme une fonction, Le navigateur sait quand un élément est retiré, Vérifier les deux versions, Template Method, le squelette et les crochets, Les classes et les fonctions dans les frameworks réels, Nuance, ce que coûte chaque version
  12. Frameworks 4, des signaux qui s'abonnent seulsRepérer le besoin, Construire Signal et effet, Vérifier sans navigateur, Réécrire l’interface avec des signaux, Une fuite dans les effets imbriqués, Les signaux dans les frameworks réels, Nuance, ce que notre version ne fait pas
  13. Frameworks 5, monter et nettoyerUne liste de réservations qui fuit, Construire les portées, Réécrire l’interface, Le nettoyage dans les frameworks réels, Nuance, ce qui reste à surveiller
  14. Frameworks 6, fournir les servicesRegrouper l’état et les actions dans une classe, Comment un composant obtient-il l’état ?, Fournir et injecter, Réécrire les composants, L’injection dans les frameworks réels, Nuance, injection ou localisateur
  15. Frameworks 7, comparer avec les frameworks réelsCe que vous avez construit, Le DOM virtuel de React, Ce que font les compilateurs, Ce qui manque encore à notre framework, Essayer dans votre projet, Pour aller plus loin
  16. SOLID, comprendre les cinq principesS, responsabilité unique, O, ouvert à l’extension, fermé à la modification, L, substitution de Liskov, I, ségrégation des interfaces, D, inversion des dépendances, Situer SOLID et Clean Code, Choisir quel principe examiner, Exercice, une réservation ferme dans une liste
  17. UML, lire et dessiner un diagramme de classesChoisir ce que le diagramme doit montrer, Lire une classe, Reconnaître les relations, Lire les nombres aux extrémités, Dessiner son premier modèle, À vous de compléter
  18. Choisir une création avec FactoryLire une fonction qui crée un objet, Lire une fabrique qui garde une configuration, Essayer une création liée aux tarifs, Distinguer les noms des sources
  19. S'entraîner et vérifier sa compréhensionConstruire des créatures en trois étapes, Réutiliser les acquis dans un autre projet
  20. GlossaireObjets et relations, Lire les mots d’une classe, Contrats et types, Fonctions et organisation du code, Patterns et frameworks
  21. Ressources et bibliographiePOO, contrats et SOLID, Diagrammes UML, Structures des patterns, Patterns JavaScript, Frameworks et cycle d’exécution, Repères visuels, Livres, Pour pratiquer

POO, design patterns et frameworks

Vous allez créer des créatures avec leur propre énergie, puis faire évoluer une application de réservation d’ateliers. Ces cas pratiques permettent de découvrir les classes, l’encapsulation, les interfaces et la collaboration entre objets, puis les design patterns et les frameworks.

Les exemples utilisent TypeScript. Pour démarrer, il suffit de savoir écrire des fonctions et manipuler des objets et des tableaux.

La POO dans le code web

Si vous écrivez du JavaScript pour le navigateur, vous utilisez déjà des objets, même sans les nommer ainsi. Un bouton de la page est une instance de HTMLButtonElement, une classe qui hérite de HTMLElement, puis de Element, Node et EventTarget. La méthode addEventListener vient de cette dernière classe et applique le pattern Observer, que le cours étudie. Angular, NestJS et Lit écrivent aussi leurs composants et leurs services en classes, comme Laravel et Symfony en PHP. La POO sert donc dans le développement web, et pas seulement en Java ou dans les applications de bureau.

Contrairement à Java, TypeScript n’oblige pourtant pas à tout écrire en classes. Un module, un objet écrit avec des accolades ou une fonction suffisent souvent, et React, Vue ou Svelte décrivent leurs composants avec des fonctions ou des fichiers compilés. Le cours retient donc les idées de la POO qui servent dans les deux styles : protéger les règles d’un objet, faire dépendre le code d’un contrat, et assembler des objets qui collaborent au lieu de construire de longues chaînes d’héritage. Chaque séance de pattern montre d’abord une version en classes, puis la même idée écrite avec des fonctions.

Le parcours essentiel

Ce parcours rassemble ce qu’un développeur TypeScript utilise le plus souvent dans un projet web. Suivez-le dans l’ordre, parce que chaque étape reprend le code ou les notions de la précédente.

  1. Lancez l’application de réservation et suivez une réservation dans son code avec Démarrer.
  2. Lisez les bases de la POO, qui expliquent l’encapsulation, les contrats, le typage structurel et la composition. Les sections sur l’écosystème et sur la classe abstraite peuvent attendre.
  3. Pratiquez ces notions en construisant une grenouille et un poisson avec les exercices de créatures.
  4. Faites évoluer l’application avec les cinq séances de patterns du tableau suivant. Les mises en situation décrivent les demandes auxquelles elles répondent.
  5. Construisez une interface réactive avec la série mini framework. Ses sept chapitres réutilisent Observer et l’injection de dépendances pour expliquer le fonctionnement de React, Vue, Svelte ou Angular.
Besoin dans l’applicationSéanceOù vous retrouverez l’idée en web
Ajouter un tarif solidaire.StrategyLa fonction de comparaison passée à sort, une règle de calcul reçue en paramètre.
Lire les données d’un partenaire.AdapterLa conversion d’une réponse JSON aux noms de l’application, après fetch.
Ajouter une trace autour d’une réservation.DecoratorLes middlewares de Hono, une fonction qui enveloppe fetch.
Séparer les règles de réservation de leur stockage.RepositoryLe module d’accès à la base de données d’un serveur Node.
Prévenir plusieurs destinataires après une réservation.ObserveraddEventListener, les stores et les signaux des frameworks.

Chaque séance sur un pattern utilise une copie du programme de départ. Les chapitres du mini framework se suivent et font évoluer les mêmes fichiers.

Les fiches aide-mémoire

Chaque fiche résume un pattern sur une page illustrée, avec son schéma, son code et une question pour vérifier que vous l’avez compris. Ouvrez-la à la fin de la séance, ou relisez-la avant un exercice.

  • Strategy range plusieurs façons de faire la même chose derrière un même contrat.
  • Observer prévient tous les abonnés quand l’état d’un objet change.
  • Adapter et Facade traduisent une interface étrangère ou cachent plusieurs objets derrière une méthode simple.
  • Decorator ajoute un comportement autour d’un objet sans changer son contrat.
  • Template Method fixe l’ordre des étapes et laisse les sous-classes en remplir certaines, comme le font les frameworks.

Approfondir

Les chapitres suivants restent utiles, mais ils peuvent attendre la fin du parcours. Lisez-les quand un problème précis se présente, ou pour préparer un entretien technique ou un jury, où l’on demande souvent ce vocabulaire et ces diagrammes.

  • SOLID détaille cinq principes de conception. Dans un projet web, ce sont surtout S et D qui servent. S demande de séparer le code qui change pour des raisons différentes, comme le calcul d’un tarif et le texte d’une confirmation. D demande que les règles dépendent d’un contrat, puis qu’on leur fournisse le stockage ou le client d’API qui le respecte. Les séances Strategy et Repository appliquent déjà ces deux idées.
  • UML apprend à lire et à dessiner un diagramme de classes. Pour suivre le cours, savoir lire une classe et ses relations suffit.
  • Factory distingue plusieurs façons de regrouper la création des objets. En TypeScript, une fonction qui renvoie un objet couvre la plupart des besoins.

Le glossaire rassemble le vocabulaire, et la bibliographie commentée classe les lectures par sujet.

Les familles de patterns

Le catalogue Design Patterns, écrit par Gamma, Helm, Johnson et Vlissides, est souvent appelé GoF pour Gang of Four. Il distingue trois familles selon le problème traité. Le schéma suivant situe les patterns de ce catalogue étudiés dans le cours.

flowchart LR accTitle: Trois familles de patterns GoF accDescr: Les patterns de création organisent la fabrication des objets, comme Factory Method et Abstract Factory. Les patterns structurels organisent leur assemblage, comme Adapter et Decorator. Les patterns comportementaux organisent leurs collaborations, comme Strategy et Observer. Creation[Création] --> Fabriques[Factory Method et Abstract Factory] Structure[Structure] --> Adapter[Adapter] Structure --> Decorator[Decorator] Comportement[Comportement] --> Strategy[Strategy] Comportement --> Observer[Observer]

« Factory » est un terme général. Une fonction de fabrique simple n’est pas nécessairement un Factory Method. Repository appartient aux patterns d’architecture d’entreprise, hors du catalogue GoF. Patterns.dev présente aussi des organisations propres au Web, comme les modules JavaScript et la séparation entre données et présentation.

Le catalogue GoF décrit 23 patterns orientés objet. Reconnaître ceux du schéma suffit pour commencer. Les fiches de Refactoring.Guru présentent leurs structures classiques et leur pseudo-code, ainsi que les patterns que le cours n’étudie pas.

Repères historiques

Simula a introduit la programmation par objets en 1967, puis Smalltalk l’a diffusée depuis le Xerox PARC dans les années 1970. Un objet regroupe un état et des méthodes. L’encapsulation lui permet de protéger ses règles, et le polymorphisme permet à plusieurs objets de répondre au même appel chacun à sa manière.

Interface Smalltalk en noir et blanc, avec un navigateur de classes, une liste de méthodes et du code.
Le navigateur Smalltalk affiche les classes et leurs méthodes. Voir la capture d'origine. Le texte de Butler W. Lampson sur le logiciel de l'Alto donne le contexte historique de cet environnement.

Démarrer, lire une application qui fonctionne

Lancez un programme d’inscription à des ateliers, puis suivez une réservation dans son code. Il utilise un tarif unique, garde les données en mémoire et simule les notifications. Vous le ferez évoluer dans les séances suivantes.

Exécuter le point de départ

  1. Téléchargez ateliers.ts dans un dossier de travail.
  2. Ouvrez un terminal dans ce dossier et assurez-vous que Node.js 24 est installé.
  3. Exécutez le fichier.
node ateliers.ts

Le programme affiche exactement les quatre lignes suivantes si ses assertions réussissent.

Vérifications réussies.
Ateliers au catalogue : 2
Réservations actives : 2
Notifications en mémoire : 4

Ces nombres résultent des opérations exécutées par les assertions en fin de fichier. Une assertion compare un résultat à ce qui est attendu. Si elle échoue, le programme s’arrête.

Suivre une réservation dans le code

Le programme repose sur une classe, Inscriptions. Son constructeur reçoit le catalogue des ateliers, et ses champs privés conservent les réservations, les notifications et le prochain identifiant. Le code extérieur passe par ses méthodes publiques. Les assertions lisent l’état avec deux getters, des accesseurs qui exécutent du code lors de la lecture d’une propriété. Ceux du programme renvoient des copies, si bien que modifier les valeurs reçues ne change pas les réservations et les notifications internes.

Repérez d’abord les données à l’aide du tableau suivant, puis lisez les opérations qui les utilisent :

Élément du fichierCe qu’il représente
ateliersLe catalogue, avec les capacités et les prix, transmis au constructeur.
#reservationsLes inscriptions actives et leur montant enregistré. Le getter reservations en renvoie une copie.
#notificationsDes messages conservés en mémoire pour observer les confirmations et annulations. Le getter notifications en renvoie une copie.
#prochainIdLe compteur qui attribue les identifiants.
reserver()La recherche de l’atelier, les contrôles, puis l’ajout d’une réservation.
annuler()Le retrait d’une réservation et l’ajout d’un message.
listerAteliers()Le calcul du nombre de places encore disponibles.

Le tableau ateliers reste partagé entre le programme et l’objet inscriptions. Quand les assertions modifient un prix au catalogue, la classe voit ce nouveau prix pour les réservations suivantes.

Retrouvez l’appel inscriptions.reserver(1, " Lea@example.test "), puis suivez la valeur de l’adresse jusqu’à son enregistrement. Repérez ensuite les lignes qui peuvent modifier une réservation ou une notification. Remarquez que les contrôles provoquant un refus arrivent avant ces modifications.

Trois questions pour suivre le code :

  1. Pourquoi une seconde inscription avec LEA@example.test est-elle refusée ?
  2. Où trouve-t-on le nombre de places restantes ? Est-ce une donnée mémorisée ou un calcul ?
  3. Que devient le montant d’une ancienne réservation quand le prix de l’atelier change ?

Le programme retire les espaces autour de l’adresse et la convertit en minuscules. Cette normalisation lui permet de reconnaître le doublon malgré les différences d’écriture. Le nombre de places disponibles se calcule dans la méthode listerAteliers. Quant au montant, il est enregistré dans la réservation lors de sa création et n’est pas recalculé depuis le catalogue à chaque lecture.

Les règles de réservation

Le programme respecte les règles suivantes, appelées invariants.

  • Une réservation refusée n’ajoute ni réservation ni notification.
  • Une adresse normalisée ne permet pas de réserver deux fois le même atelier.
  • La capacité n’est pas dépassée dans cette simulation séquentielle, où les réservations sont traitées l’une après l’autre.
  • Un montant enregistré reste inchangé après une modification du prix au catalogue.
  • Une annulation libère une place et produit une notification.
  • Les identifiants ne sont pas réutilisés après une annulation.

Les assertions vérifient ces comportements avant et après une refactorisation, c’est-à-dire une réorganisation du code qui conserve son comportement observable.

Le modèle en objets

Le fichier représente le participant par son adresse de courriel. Les ateliers et les réservations sont de simples objets de données, et seule Inscriptions est une classe. Le modèle UML d’ateliers et de réservations montre une autre représentation de ces mêmes notions, avec trois classes.

Introduire une variation

Essayez d’ajouter un tarif avec Strategy, puis relancez node ateliers.ts pour vérifier le résultat. Les mises en situation proposent les évolutions suivantes. Repartez du programme initial pour chaque séance.

Limites de la simulation

Les données restent en mémoire et ne sont pas conservées entre deux exécutions. De même, les notifications sont des chaînes de caractères enregistrées en mémoire pour observer le résultat des opérations. Aucun courriel n’est réellement envoyé.

Les bases de la programmation orientée objet

Dans l’application de réservation, on vérifie qu’il reste une place et que la personne n’est pas déjà inscrite avant d’ajouter son inscription. La programmation orientée objet permet de réunir un état et les opérations qui le consultent ou le modifient. On peut ainsi confier ces règles à un objet, plutôt que demander à chaque code qui l’utilise de les vérifier lui-même. Ce code est son appelant.

On commence avec des créatures qui se présentent, avancent et se reposent, puis on retrouve ces notions dans l’application de réservation d’ateliers. Chaque exemple s’exécute séparément, même lorsque plusieurs blocs reprennent un même nom de classe. Le glossaire rassemble le vocabulaire du cours.

Objets, classes et encapsulation

L’état d’un objet correspond aux valeurs qu’il conserve dans ses propriétés à un moment donné. Ses méthodes sont les opérations que l’on peut lui demander d’exécuter. Une créature peut ainsi conserver son énergie et proposer une méthode pour avancer en utilisant cette énergie.

Une classe décrit comment créer des objets de même structure et les méthodes qu’ils proposent. Chaque objet créé à partir de cette classe est une instance, avec son propre état. La classe n’est donc pas une créature particulière, mais la description qui permet d’en créer plusieurs. JavaScript permet aussi de créer des objets sans classe, notamment avec des accolades, comme on le verra avec les réservations.

Lire une classe pas à pas

Une créature possède un nom et sait se présenter. La classe décrit ces deux éléments. Les appels à new créent les deux créatures, puis les assertions vérifient leur présentation et leur identité.

import assert from "node:assert/strict";

class Creature {
	nom: string;

	constructor(nom: string) {
		this.nom = nom;
	}

	sePresenter(): string {
		return `Je m'appelle ${this.nom}.`;
	}
}

const grenouille = new Creature("René");
const poisson = new Creature("Mina");

assert.equal(grenouille.sePresenter(), "Je m'appelle René.");
assert.equal(poisson.sePresenter(), "Je m'appelle Mina.");
assert.notEqual(grenouille, poisson);

class Creature donne un nom à la classe. À l’intérieur des accolades, nom: string déclare une propriété qui contiendra du texte. Cette annotation indique à TypeScript quel type vérifier avant l’exécution. Elle ne fournit pas de valeur à la propriété. Le constructeur s’en chargera pour chaque objet créé.

L’expression new Creature("René") crée une instance et appelle son constructor, le constructeur. Le paramètre nom reçoit la valeur "René". Dans this.nom = nom, la partie droite désigne ce paramètre, tandis que this.nom désigne la propriété de l’objet en cours de construction. L’affectation conserve donc le nom dans cet objet.

Le second new, avec "Mina", crée un autre objet et appelle à nouveau le constructeur. Chaque instance conserve son propre nom. Changer celui de la grenouille ne changerait donc pas celui du poisson.

sePresenter() est une méthode d’instance. Dans l’appel grenouille.sePresenter(), this désigne grenouille. La méthode lit donc son nom. Dans poisson.sePresenter(), elle lit celui de poisson. L’annotation : string après les parenthèses indique que la méthode renvoie du texte. On peut retrouver cette syntaxe dans la présentation des classes de MDN.

Protéger les règles de l’objet

Dans le premier exemple, l’appelant peut lire ou remplacer le nom. Avec une énergie limitée, autoriser n’importe quelle affectation permettrait de donner une énergie négative à la créature. On va donc modifier son énergie uniquement en avançant ou en se reposant.

Le code appelant passe alors par les opérations publiques de l’objet, sans devoir reproduire ses contrôles ni connaître son organisation interne. Les membres accessibles à l’appelant constituent son interface publique. Le mot « interface » désigne ici ce que l’objet expose, pas encore une déclaration de type TypeScript.

L’encapsulation consiste à contrôler les accès à l’état d’un objet pour préserver ses règles. Ici, une créature commence avec 100 points d’énergie et doit rester entre 0 et 100. Se déplacer lui coûte 10 points, tandis que se reposer lui en rend 20, sans dépasser 100.

flowchart TB accTitle: Une classe décrit plusieurs objets avec leur propre état accDescr: Creature définit une variable de classe, une variable d'instance et deux méthodes. La grenouille et le poisson ont chacun leur énergie. Après avancer(), la grenouille est à 90. Le poisson reste à 100. Classe["Classe Creature<br/>Variable de classe : energieMax = 100<br/>Variable d'instance : #energie<br/>Méthodes : avancer(), seReposer()"] Classe -->|new Creature| Grenouille["Objet grenouille<br/>Instance de Creature<br/>Après avancer() : #energie = 90"] Classe -->|new Creature| Poisson["Objet poisson<br/>Instance de Creature<br/>#energie = 100"]

Les propriétés propres à chaque objet sont appelées variables d’instance. Les méthodes avancer() et seReposer() agissent sur l’énergie de l’objet qui les reçoit. Dans le schéma, déplacer la grenouille ne consomme donc pas l’énergie du poisson. En revanche, la variable de classe, déclarée static, appartient à Creature et se consulte avec Creature.energieMax. Elle indique une limite commune, pas une réserve d’énergie partagée. Elle est ici readonly, donc TypeScript interdit de lui affecter une autre valeur.

class Creature {
  static readonly energieMax = 100;
  #energie = Creature.energieMax;

  get energie(): number {
    return this.#energie;
  }

  avancer(): boolean {
    if (this.#energie < 10) return false;
    this.#energie -= 10;
    return true;
  }

  seReposer(): void {
    this.#energie = Math.min(Creature.energieMax, this.#energie + 20);
  }
}

const grenouille = new Creature();
grenouille.avancer();
console.log(grenouille.energie); // 90

Le champ #energie est privé. L’appelant ne peut pas le lire ou le modifier directement. Le getter, c’est-à-dire l’accesseur en lecture, permet de consulter sa valeur depuis l’extérieur. La lecture grenouille.energie, sans parenthèses, exécute le corps de cet accesseur et renvoie le nombre conservé dans le champ privé. Elle ne rend pas ce champ modifiable.

Au premier déplacement, le contrôle constate que la créature possède au moins 10 points. La méthode en retire 10 et renvoie true pour signaler la réussite. C’est pourquoi le programme affiche 90. Lorsque l’énergie est insuffisante, la méthode renvoie false avant la soustraction et laisse l’état inchangé. Lors du repos, le choix du minimum entre 100 et l’énergie augmentée empêche de dépasser la limite.

Dans les signatures, boolean annonce une réponse true ou false, tandis que void indique que l’appelant n’attend pas de valeur de retour. Une méthode peut donc modifier l’état sans renvoyer de résultat.

La limite de 0 à 100 est un invariant, c’est-à-dire une règle vraie à la création de l’objet et préservée après ses opérations publiques. avancer() et seReposer() la maintiennent grâce à leurs contrôles. Le code appelant utilise ces méthodes sans reproduire leurs vérifications.

Un setter, ou accesseur en écriture, exécute du code lors de l’affectation d’une propriété et peut contrôler la valeur reçue. S’il acceptait n’importe quel nombre, l’énergie pourrait devenir -50 ou 500. Ici, les méthodes de déplacement et de repos suffisent, sans setter pour choisir directement la nouvelle énergie.

En TypeScript, une propriété ou une méthode est public par défaut, comme nom et sePresenter() dans le premier exemple. Le mot private, dans une déclaration telle que private energie = 100, réserve l’accès au code de cette classe pendant la vérification des types. protected autorise aussi le code des sous-classes, que l’on découvre avec l’héritage.

Les modificateurs TypeScript private et protected disparaissent du JavaScript exécuté. Ils contrôlent les accès pendant la vérification des types. La syntaxe JavaScript #energie contrôle aussi l’accès au champ à l’exécution. Ce champ privé n’est pas une propriété publique nommée energie que l’on pourrait retrouver avec une chaîne de caractères.

De même, readonly est un contrôle TypeScript. Dans static readonly energieMax = 100, il interdit de réaffecter ce membre après son initialisation. Appliqué à une propriété qui contient un objet, il empêche d’y affecter une autre référence, sans interdire les modifications des propriétés de cet objet.

Distinguer deux objets et deux références

L’encapsulation oblige aussi à distinguer l’objet des variables qui le désignent. Deux appels à new Creature("René") créent deux objets, même s’ils contiennent le même nom. À l’inverse, l’affectation b = a ne crée aucun objet. Elle copie une référence, c’est-à-dire une valeur qui permet de retrouver le même objet. a et b deviennent alors deux noms pour cet objet. Une modification autorisée par l’un est donc visible par l’autre.

Le même mécanisme s’applique aux objets écrits avec des accolades. On peut l’observer avec une réservation dont les champs sont des nombres et du texte.

import assert from "node:assert/strict";

const reservation = { id: 1, email: "lea@example.test", montantCentimes: 2000 };
const autreReservation = { id: 1, email: "lea@example.test", montantCentimes: 2000 };
const alias = reservation;

assert.equal(reservation === autreReservation, false);
assert.equal(reservation === alias, true);
alias.montantCentimes = 1500;
assert.equal(reservation.montantCentimes, 1500);

const copie = { ...reservation };
copie.montantCentimes = 1000;
assert.equal(reservation.montantCentimes, 1500);
assert.equal(copie === reservation, false);

const atelier = { titre: "Photo", participants: ["Léa"] };
const copieAtelier = { ...atelier };
copieAtelier.participants.push("Sam");
assert.equal(copieAtelier.participants === atelier.participants, true);
assert.deepEqual(atelier.participants, ["Léa", "Sam"]);

Pour des objets, === compare l’identité, donc le fait de désigner le même objet. Il ne compare pas le contenu des propriétés. La première assertion vaut ainsi false, même si les deux réservations possèdent initialement les mêmes valeurs, y compris le même identifiant. Le champ qui identifie une réservation dans l’application ne détermine pas l’identité de l’objet JavaScript. Après const alias = reservation, modifier alias.montantCentimes modifie au contraire la réservation que les deux variables désignent. L’alias n’en est pas une copie indépendante.

const interdit de réaffecter une variable à une autre valeur. Il n’interdit pas de modifier les propriétés de l’objet qu’elle désigne. C’est pourquoi l’affectation à alias.montantCentimes est possible dans cet exemple.

La syntaxe de décomposition ... crée ici une copie superficielle. { ...reservation } produit un nouvel objet avec les valeurs de ses propriétés. Modifier le nombre copie.montantCentimes laisse donc l’original intact.

Dans atelier, en revanche, la propriété participants contient une référence vers un tableau. La copie conserve cette référence, donc les deux objets partagent le même tableau. Ajouter Sam depuis la copie modifie ce tableau partagé, ce que vérifie la dernière assertion. « Superficielle » signifie que la copie s’arrête au premier niveau de propriétés, sans recopier les objets et les tableaux qu’elles désignent.

Dans la séance Repository, les champs d’une réservation sont tous primitifs, comme les nombres et les chaînes de cet exemple. Une copie superficielle suffit alors à éviter qu’un résultat reçu serve à modifier les enregistrements internes. Si on ajoute un tableau ou un objet imbriqué, il faudra revoir cette garantie.

Modéliser un écosystème

Dans une simulation d’écosystème, chaque créature peut gérer son énergie tandis que le monde gère la météo et les événements. Le monde demande un déplacement, et la créature l’accepte ou le refuse selon son énergie. Un journal affiche ce qui se passe.

Murmur simule des écosystèmes, dont la forêt Plot ci-dessous. On peut s’en inspirer pour imaginer notre mare Pond. Le découpage proposé ici est un exemple pédagogique, sans décrire l’architecture interne de Murmur.

L'écosystème forestier Plot de Murmur, avec des créatures, les conditions météo et un journal d'événements.
Plot, un écosystème de Murmur, projet de oio et mattering.

Contrats et polymorphisme

On a décrit ce qu’un déplacement peut faire à l’énergie de la créature et comment l’appelant sait s’il a réussi. Ces précisions forment un contrat. L’appelant peut demander un déplacement, mais celui-ci peut être refusé. En cas de refus, il sait que l’énergie reste inchangée. Un contrat indique donc les opérations disponibles, leurs conditions d’utilisation et les garanties sur leur résultat ou leurs effets.

Une interface TypeScript décrit la partie que le système de types peut vérifier, comme les noms des méthodes et leurs types de retour. Dans l’exemple suivant, on veut décrire un animal qui sait nager. La fonction n’a besoin de connaître ni son espèce ni ses autres capacités. On nomme donc la capacité attendue plutôt qu’une classe particulière. L’interface déclare une méthode sans argument qui renvoie du texte, mais ne fournit pas le code de cette méthode.

TypeScript utilise un typage structurel. Le compilateur compare les membres et leurs types pour vérifier leur compatibilité. Une classe peut donc satisfaire une interface sans déclarer qu’elle l’implémente, c’est-à-dire qu’elle fournit les membres décrits. La documentation TypeScript sur la compatibilité des types détaille cette vérification.

import assert from "node:assert/strict";

interface Nageur {
	nager(): string;
}

class Canard {
	nager(): string {
		return "Le canard nage.";
	}

	voler(): string {
		return "Il vole.";
	}
}

class Poisson {
	nager(): string {
		return "Le poisson nage.";
	}
}

function decrire(animal: Nageur): string {
	return animal.nager();
}

assert.equal(decrire(new Canard()), "Le canard nage.");
assert.equal(decrire(new Poisson()), "Le poisson nage.");

Ni Canard ni Poisson ne mentionnent Nageur. TypeScript accepte pourtant leurs instances, parce que chacune possède une méthode nager() qui renvoie du texte. L’interface exprime ici une capacité commune, pas un ancêtre commun. La section Lire extends, super et implements montre comment déclarer ce lien explicitement.

Le polymorphisme permet à decrire d’appeler la même opération sur des objets de classes différentes. La ligne animal.nager() exécute la méthode du canard ou celle du poisson, selon l’objet reçu. La fonction n’a pas besoin de tester l’espèce pour choisir la bonne méthode. Le canard possède aussi une méthode voler() que cette fonction n’a pas besoin de connaître. Elle utilise seulement la capacité de nage annoncée par son paramètre.

Dans l’application d’ateliers, on retrouvera cette organisation avec les tarifs. Un calcul de réservation pourra demander un montant à une règle de tarification, sans connaître le détail de chaque tarif. Le contrat devra alors préciser davantage qu’un type de retour, notamment l’unité du montant.

Duck typing et typage structurel

Le duck typing, ou typage canard, consiste à utiliser les opérations d’un objet sans exiger une classe précise. L’image du canard donne son nom à cette approche. Si l’objet fournit l’opération attendue, le code l’utilise. Le glossaire Python décrit ce style, également courant en JavaScript.

En JavaScript, cet appel se résout à l’exécution. Il ne nécessite ni interface déclarée ni test de classe. L’objet écrit directement avec des accolades dans le premier appel suffit, parce qu’il fournit la méthode attendue. Le second objet ne la fournit pas, et l’appel échoue.

import assert from "node:assert/strict";

function decrire(animal) {
	return animal.nager();
}

assert.equal(decrire({ nager: () => "Je nage." }), "Je nage.");
assert.throws(() => decrire({ voler: () => "Je vole." }), TypeError);

La première assertion vérifie la phrase renvoyée. La seconde vérifie qu’essayer d’appeler la méthode absente provoque une erreur. Le code ne rejette pas une espèce d’animal, il tente une opération que l’objet ne permet pas d’exécuter.

Le typage structurel de l’exemple TypeScript précédent effectue une vérification avant l’exécution. Le duck typing désigne la manière d’utiliser l’objet, pas une vérification du compilateur. Les deux approches permettent d’utiliser des objets indépendants d’une hiérarchie de classes.

Même lorsque le compilateur accepte le code, la présence de nager() ne prouve pas le respect du contrat comportemental. Une méthode peut renvoyer une chaîne tout en produisant un effet interdit ou en levant une erreur inattendue. Le compilateur contrôle des types, tandis que la description du contrat et les tests précisent le comportement attendu.

Cohésion, couplage et responsabilités

Pour examiner comment les objets travaillent ensemble, on regarde ce qui est regroupé et ce que chaque partie connaît des autres. Un module est une partie du programme qui regroupe du code, par exemple dans un fichier. Sa cohésion est forte lorsque ses éléments contribuent au même rôle. Des calculs de tarifs peuvent ainsi former un ensemble cohérent sans prendre aussi en charge le texte des confirmations.

Le couplage décrit ce qu’un module doit connaître des autres pour fonctionner. Si chaque écran connaît les champs propres à un fournisseur, changer ce fournisseur oblige à modifier plusieurs écrans. On peut limiter cette connaissance en intercalant un contrat adapté aux opérations de réservation, comme dans le schéma suivant.

flowchart LR accTitle: Dépendances d'une réservation accDescr: L'interface utilise les règles métier. Les règles dépendent d'un contrat de réservations que le stockage implémente. UI[Interface] -->|appelle| Metier[Règles métier] Metier -->|utilise| Contrat[Contrat des réservations] Stockage[Stockage] -.->|implémente| Contrat

L’interface appelle le code qui applique les règles de réservation. Ces règles utilisent un contrat pour demander l’enregistrement ou la lecture des réservations. Le stockage fournit les opérations annoncées par ce contrat. Les règles peuvent ainsi exprimer ce dont elles ont besoin sans connaître le format propre au stockage.

Les flèches représentent ce que chaque partie du code doit connaître pour fonctionner. Les données peuvent circuler dans les deux sens. Avec ce découpage, changer le format du stockage ne demande pas de modifier les écrans ni les règles de réservation.

Les principes SOLID

L’encapsulation explique comment un objet protège son état. Les contrats expliquent comment d’autres codes peuvent l’utiliser. Les principes SOLID prolongent ces questions en examinant les responsabilités, les variations et les dépendances. Le chapitre SOLID les illustre avec des exemples de réservation avant et après modification.

Remplacer sans rompre le contrat

Le polymorphisme suppose que les objets proposés à l’appelant tiennent les promesses sur lesquelles son code s’appuie. Une réservation non annulable ne peut donc pas tenir la promesse d’un type qui garantit l’annulation. Présenter une méthode portant le bon nom ne résout pas cette incompatibilité. Le principe de Liskov explique cette difficulté. L’exemple corrigé de réservation distingue les capacités de consultation et d’annulation, puis vérifie leurs garanties.

Composition et héritage

L’héritage et la composition permettent de partager un comportement ou de le faire varier. Hériter, c’est définir une classe à partir d’une autre en reprenant ses membres. Une CommandePromotionnelle hérite ainsi de Commande et doit en respecter les promesses pour être utilisée à sa place.

Composer, c’est assembler des objets ou des fonctions qui travaillent ensemble. Au lieu d’hériter d’une nouvelle sorte de commande pour chaque promotion, une commande peut recevoir une règle de réduction et lui confier le calcul de la remise. Le traitement de la commande utilise le résultat, tandis que la règle choisie détermine le calcul. Un changement de promotion revient alors à lui fournir une autre règle.

classDiagram direction LR accTitle: Une commande peut hériter ou utiliser un collaborateur accDescr: CommandePromotionnelle hérite de Commande. Une commande connaît une règle de réduction. Le triangle creux indique l'héritage, la flèche simple indique la collaboration. Commande <|-- CommandePromotionnelle Commande --> Reduction : utilise

On lit « une commande promotionnelle est une commande » et « une commande a une réduction ». Le triangle pointe vers la classe parente, dont la sous-classe reprend le code. La flèche simple représente le collaborateur utilisé, auquel la commande confie une partie du travail. Ces deux liens peuvent coexister. L’héritage lie les classes entre elles, tandis que la collaboration demande au collaborateur de satisfaire le contrat attendu. Partager quelques lignes de code ne suffit donc pas à justifier un héritage.

L’article Héritage vs composition de LeanDeep introduit cette distinction avec des exemples Python. Les formulations « est un » et « a un » aident à distinguer ces deux relations. Le chapitre UML, lire et dessiner un diagramme de classes explique les flèches, les multiplicités et le sens plus précis de la composition en UML.

Dans le code web, l’héritage sert surtout à étendre une classe fournie par la plateforme ou par une bibliothèque. Un élément HTML personnalisé hérite ainsi de HTMLElement, et une erreur propre à l’application peut hériter de Error pour garder sa pile d’appels et se reconnaître avec instanceof. On dépasse rarement ce premier niveau. Pour faire varier un comportement, on fournit plutôt un objet ou une fonction à la classe qui l’utilise, ce qui revient à composer.

Le chapitre Strategy utilise cette composition pour changer une règle de calcul. Réorganiser le code en conservant son comportement est un refactoring. Les principes DRY, KISS et YAGNI aident à limiter les répétitions et la complexité.

Lire extends, super et implements

Le code suivant reprend les créatures pour distinguer deux relations. Presentable décrit une opération que l’appelant veut utiliser. Creature fournit le code de cette opération, puis Grenouille hérite de ce code et le complète. Satisfaire le contrat et hériter d’une classe sont donc deux décisions distinctes.

import assert from "node:assert/strict";

interface Presentable {
	sePresenter(): string;
}

class Creature implements Presentable {
	protected nom: string;

	constructor(nom: string) {
		this.nom = nom;
	}

	sePresenter(): string {
		return `Je m'appelle ${this.nom}.`;
	}
}

class Grenouille extends Creature {
	constructor(nom: string) {
		super(nom);
	}

	override sePresenter(): string {
		return `${super.sePresenter()} Je vis près de la mare.`;
	}
}

const grenouille = new Grenouille("René");
const presentation: Presentable = grenouille;
assert.equal(presentation.sePresenter(), "Je m'appelle René. Je vis près de la mare.");

extends Creature fait de Grenouille une sous-classe de Creature. Dans son constructeur, super(nom) appelle le constructeur parent, qui initialise le nom. Dans un constructeur de sous-classe, cet appel doit avoir lieu avant d’utiliser this. La propriété nom est ici protected, donc la classe parente et ses sous-classes peuvent l’utiliser dans le code TypeScript.

La méthode sePresenter() existe déjà dans la classe parente. La sous-classe la redéfinit pour compléter la présentation. super.sePresenter() exécute la version parente sur la même grenouille, puis la méthode ajoute une phrase. Cet appel ne crée pas une seconde créature. Le mot TypeScript override permet au compilateur de vérifier qu’une méthode de ce nom existe bien dans la classe parente.

implements Presentable demande une autre vérification. TypeScript contrôle que Creature fournit l’opération décrite par l’interface, avec les types compatibles. Ce mot n’ajoute donc aucun code à la classe. Avec extends, on hérite du code de la classe parente. Avec implements, on annonce explicitement un contrat de types que son propre code doit satisfaire. La documentation des classes TypeScript précise cette distinction.

L’affectation à presentation permet ensuite d’utiliser la grenouille à travers ce contrat. Elle ne crée pas un nouvel objet et ne retire pas les capacités de la grenouille. Elle indique quelles opérations le code peut utiliser à travers cette variable. L’appel final exécute donc toujours la méthode redéfinie par la grenouille.

Le typage structurel accepte aussi un objet qui possède une méthode sePresenter(): string, sans écrire implements Presentable. Le principe de Liskov complète cette compatibilité des types par le respect du comportement attendu.

Comparer une interface et une classe abstraite

Une interface décrit les membres attendus, sans fournir leur code. Une classe abstraite peut aussi conserver un état et fournir des méthodes communes, tout en laissant certaines méthodes à écrire dans les sous-classes. Elle associe donc du code partagé à des opérations encore incomplètes.

Dans l’exemple suivant, le poisson hérite d’une façon de conserver son nom et de se présenter, mais fournit sa propre façon de nager. L’observateur demande seulement la capacité de nage, ce qui lui permet aussi d’accepter un robot indépendant de cette hiérarchie.

import assert from "node:assert/strict";

interface Nageur {
	nager(): string;
}

abstract class CreatureAquatique implements Nageur {
	protected nom: string;

	constructor(nom: string) {
		this.nom = nom;
	}

	sePresenter(): string {
		return `Je m'appelle ${this.nom}.`;
	}

	abstract nager(): string;
}

class Poisson extends CreatureAquatique {
	nager(): string {
		return `${this.nom} nage sous l'eau.`;
	}
}

class Robot {
	nager(): string {
		return "Le robot avance à la surface.";
	}
}

function observerNage(nageur: Nageur): string {
	return nageur.nager();
}

const poisson = new Poisson("Mina");
assert.equal(poisson.sePresenter(), "Je m'appelle Mina.");
assert.equal(observerNage(poisson), "Mina nage sous l'eau.");
assert.equal(observerNage(new Robot()), "Le robot avance à la surface.");

abstract class CreatureAquatique fournit le constructeur, le champ et la présentation. La déclaration abstract nager(): string annonce une méthode sans corps. Puisque cette classe est abstraite, TypeScript interdit de créer directement une de ses instances. Une sous-classe concrète telle que Poisson doit fournir le code manquant avant de pouvoir être instanciée.

Poisson hérite ici de la présentation sans la réécrire. Comme Poisson ne déclare pas de constructeur, son constructeur par défaut transmet le nom au constructeur de CreatureAquatique. À l’inverse, Robot n’hérite d’aucune de ces classes et ne déclare pas implements Nageur. Ses instances satisfont pourtant l’interface Nageur, donc observerNage les accepte.

Une interface décrit la capacité dont l’appelant a besoin. Une classe abstraite partage aussi un état et du code entre ses sous-classes. Ici, les créatures aquatiques partagent leur nom et leur présentation, tandis que l’interface Nageur permet d’observer aussi le robot.

Pour pratiquer, créez vos créatures en trois étapes.

Mise en situation

L’application de réservation permet de consulter les ateliers, de réserver une place et d’annuler une inscription. Voici trois évolutions à essayer.

Situation 1, des tarifs qui varient

Ajoutez un tarif solidaire qui réduit le prix de moitié, arrondi au centime le plus proche. Pour un prix de 2 001 centimes, la réservation doit enregistrer 1 001 centimes. Son montant reste ensuite inchangé, même si le prix du catalogue évolue.

La séance Strategy montre comment ajouter ce tarif à reserver, d’abord avec une condition, puis avec un objet qui calcule le montant. La séance Factory regroupe le choix du tarif quand plusieurs endroits en ont besoin.

Situation 2, une API externe et un enregistrement qui échoue

Un partenaire fournit les places disponibles dans un champ remaining_seats. Branchez cette information sur la liste des ateliers avec Adapter. Une réponse invalide ou une panne doit être signalée, sans être confondue avec un atelier complet.

On souhaite aussi conserver les réservations après un redémarrage. La séance Repository prépare les échanges avec le stockage et simule un échec d’écriture. La confirmation ne doit être envoyée qu’après un enregistrement réussi. Le stockage durable reste à ajouter.

Situation 3, plusieurs réactions puis une interface

Après une réservation confirmée, l’application ajoute une entrée dans un journal, envoie un courriel et actualise les places disponibles. L’échec du courriel laisse la réservation confirmée. Quand la vue est retirée, elle ne reçoit plus les changements suivants.

Avec Observer, abonnez la vue et le courriel aux réservations, puis essayez de retirer la vue et de faire échouer le courriel. La série sur les frameworks reprend ces mécanismes dans une interface. Pour ajouter une trace autour d’un appel, voyez aussi Decorator.

Représenter les succès et les échecs séparément

Cette séquence montre l’ordre des appels. L’enregistrement et l’envoi du courriel ont chacun leur résultat.

sequenceDiagram accTitle: Une sauvegarde réussie reste distincte de l'envoi du courriel accDescr: L'interface demande une réservation. Si le stockage refuse l'enregistrement, le service ne demande aucun courriel et signale l'échec. Après une sauvegarde confirmée, le service demande le courriel. Son échec laisse la réservation confirmée et produit une indication séparée. participant UI as Interface participant Reservation as Service de réservation participant Stockage participant Courriel UI->>Reservation: réserver une place Reservation->>Stockage: enregistrer la réservation alt Enregistrement refusé Stockage-->>Reservation: refus Reservation-->>UI: réservation non confirmée Note over Reservation,Courriel: Aucun courriel demandé else Sauvegarde confirmée par le stockage Stockage-->>Reservation: réservation enregistrée Reservation->>Courriel: envoyer la confirmation alt Envoi accepté Courriel-->>Reservation: accepté Reservation-->>UI: réservation confirmée, envoi accepté else Échec de l'envoi Courriel-->>Reservation: erreur Reservation-->>UI: réservation confirmée, courriel en échec end end

Exporter les réservations

Ajoutez une fonction qui exporte les réservations actives avec leurs champs id, atelierId, email et montantCentimes. Essayez-la après quelques inscriptions et une annulation. L’export ne modifie aucun enregistrement et n’émet aucune notification.

Ajouter un tarif avec Strategy

Le programme des ateliers applique le prix du catalogue à chaque réservation. Vous allez y ajouter un tarif solidaire, égal à la moitié du prix normal arrondie au centime le plus proche. Pour un prix de 2 001 centimes, la réservation enregistre donc 1 001 centimes.

Travaillez dans une copie du programme de départ ateliers.ts. Le démarrage de l’atelier explique comment l’exécuter. Les assertions du départ doivent passer après chaque étape.

Le problème

La solution la plus directe ajoute un troisième paramètre à reserver et une condition dans son calcul.

const montantCentimes = tarif === "solidaire"
	? Math.round(atelier.prixCentimes / 2)
	: atelier.prixCentimes;

Elle fonctionne pour deux tarifs. Pourtant, chaque nouveau tarif, étudiant ou groupe, ajoute une branche dans reserver, une méthode qui vérifie déjà l’atelier, le courriel, les doublons et les places. Les règles de prix et les règles d’inscription finissent mélangées au même endroit.

Strategy range chaque façon de calculer dans son propre objet, derrière un contrat commun. reserver reçoit l’un de ces objets et l’utilise sans savoir lequel.

La refacto pas à pas

Étape 1, le contrat

Ajoutez cette interface au-dessus de la classe Inscriptions. Elle décrit ce que tout tarif sait faire : recevoir un prix en centimes et renvoyer un montant en centimes entiers, positif ou nul.

interface Tarif {
	appliquer(prixCentimes: number): number;
}

Étape 2, les classes qui le respectent

Écrivez une classe par tarif, juste sous l’interface. Chacune implémente Tarif avec sa propre règle.

class TarifNormal implements Tarif {
	appliquer(prixCentimes: number): number {
		return prixCentimes;
	}
}

class TarifSolidaire implements Tarif {
	appliquer(prixCentimes: number): number {
		return Math.round(prixCentimes / 2);
	}
}

Étape 3, le code qui utilise le contrat

reserver reçoit maintenant un tarif et lui confie le calcul du montant. Remplacez la méthode reserver par celle-ci. Deux choses changent, le troisième paramètre et le calcul de montantCentimes.

class Inscriptions {
	// Les champs, le constructeur et les autres méthodes ne changent pas.

	reserver(atelierId: number, email: string, tarif: Tarif = new TarifNormal()): Reservation {
		const atelier = this.#ateliers.find(a => a.id === atelierId);
		if (!atelier) throw new Error("Atelier inconnu");
		const adresse = email.trim().toLowerCase();
		if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(adresse)) throw new Error("Courriel invalide");
		if (this.#reservations.some(r => r.atelierId === atelierId && r.email === adresse))
			throw new Error("Déjà inscrit");
		if (this.#reservations.filter(r => r.atelierId === atelierId).length >= atelier.capacite)
			throw new Error("Atelier complet");
		const montantCentimes = tarif.appliquer(atelier.prixCentimes);
		if (!Number.isSafeInteger(montantCentimes) || montantCentimes < 0)
			throw new Error("Montant invalide");
		const reservation = {
			id: this.#prochainId++, atelierId, email: adresse, montantCentimes,
		};
		this.#reservations.push(reservation);
		this.#notifications.push(`Confirmation ${reservation.id} pour ${adresse}`);
		return { ...reservation };
	}
}

La valeur par défaut garde valides les appels à deux arguments du départ. Le contrôle refuse un montant impossible, comme NaN ou une valeur négative, avant d’enregistrer quoi que ce soit.

Étape 4, le branchement

Le code qui appelle reserver choisit le tarif et le lui transmet. Ajoutez ces lignes après les assertions du départ. L’atelier Photo a encore une place à cet endroit.

ateliers[1].prixCentimes = 2001;
const mia = inscriptions.reserver(2, "mia@example.test", new TarifSolidaire());
assert.equal(mia.montantCentimes, 1001);

Fournir ainsi l’objet en paramètre s’appelle une injection de dépendance. Le corrigé ateliers-tarifs.ts contient le programme complet, avec d’autres vérifications.

À vous

Ajoutez un tarif étudiant, égal à 70 % du prix arrondi au centime, sans modifier la classe Inscriptions. new TarifEtudiant().appliquer(2001) doit valoir 1 401.

La même idée avec une fonction

Le contrat Tarif ne contient qu’une méthode, qui reçoit un prix et renvoie un montant. Une fonction fait exactement cela. En TypeScript, un type de fonction peut donc remplacer l’interface, et chaque tarif devient une constante.

type CalculTarif = (prixCentimes: number) => number;

const tarifNormal: CalculTarif = prix => prix;
const tarifSolidaire: CalculTarif = prix => Math.round(prix / 2);

Dans cette version, reserver déclare tarif: CalculTarif = tarifNormal et calcule tarif(atelier.prixCentimes). L’appel devient inscriptions.reserver(2, "mia@example.test", tarifSolidaire). Vous utilisez déjà ce mécanisme pour trier un tableau. Dans [...ateliers].sort((a, b) => a.prixCentimes - b.prixCentimes), sort garde l’algorithme de tri, et la fonction de comparaison décide de l’ordre.

Les classes restent préférables quand une stratégie regroupe plusieurs opérations, par exemple un tarif qui calcule un montant et fournit aussi son libellé. Elles conviennent aussi quand un framework comme Angular ou NestJS crée les objets à partir de classes.

Pour réviser

La fiche Strategy résume le pattern sur un schéma, et la fiche de Refactoring.Guru le présente avec d’autres exemples. Si plusieurs endroits du programme doivent choisir le même tarif, la séance Factory regroupe ce choix.

Lire les places du partenaire avec Adapter

Un partenaire annonce lui aussi les places de nos ateliers, et l’application doit les afficher. Sa réponse utilise son propre vocabulaire, avec un champ remaining_seats, alors que notre code parle de « places ». Cette séance range la traduction entre les deux dans une seule classe. Elle utilise un partenaire simulé, dont vous choisissez les réponses sans contacter de service externe. Travaillez dans une copie d’ateliers.ts et gardez ses assertions de départ.

Le problème

Le partenaire fournit la méthode décrite par cette interface. Ajoutez-la après les types du fichier.

interface Partenaire {
	getAvailability(code: string): Promise<unknown>;
}

Promise indique que la réponse arrive plus tard. Le type unknown oblige à vérifier sa forme avant de la lire, car TypeScript ne contrôle pas le contenu d’une réponse externe pendant l’exécution.

La solution directe ajoute à Inscriptions une méthode qui construit le code atelier-1 attendu par le partenaire, appelle getAvailability, puis vérifie que la réponse contient un remaining_seats entier et positif. La classe des règles de réservation connaît alors le format du partenaire. Une deuxième méthode qui aurait besoin des places répéterait ce travail, et un changement de format chez le partenaire obligerait à modifier Inscriptions.

Adapter place un objet intermédiaire entre notre code et le service. Notre code demande les places avec ses propres mots, et l’adaptateur traduit cette demande dans la langue du partenaire.

La refacto pas à pas

Étape 1, le contrat

Le contrat exprime, avec le vocabulaire de l’application, la seule question qu’elle pose au partenaire, le nombre de places d’un atelier. Ajoutez-le après l’interface Partenaire.

interface Places {
	disponibles(atelierId: number): Promise<number>;
}

Étape 2, la classe qui le respecte

AdaptateurPlaces reçoit le partenaire, construit son code et vérifie sa réponse. Une réponse invalide lance une erreur au lieu de devenir zéro place, car zéro signifierait « atelier complet ». Ajoutez cette classe avant Inscriptions.

class AdaptateurPlaces implements Places {
	#api: Partenaire;

	constructor(api: Partenaire) {
		this.#api = api;
	}

	async disponibles(atelierId: number): Promise<number> {
		const data = await this.#api.getAvailability(`atelier-${atelierId}`);
		if (typeof data !== "object" || data === null ||
			!("remaining_seats" in data)) throw new Error("Format invalide");
		const places = data.remaining_seats;
		if (typeof places !== "number" || !Number.isSafeInteger(places) || places < 0)
			throw new Error("Places invalides");
		return places;
	}
}

Étape 3, le code qui utilise le contrat

Ajoutez cette méthode à Inscriptions. Elle demande les places à travers Places, sans connaître remaining_seats ni le code atelier-1.

class Inscriptions {
	// Les champs, le constructeur et les méthodes du départ ne changent pas.

	async listerAteliersPartenaire(places: Places) {
		return Promise.all(this.#ateliers.map(async atelier => ({
			...atelier,
			places: await places.disponibles(atelier.id),
		})));
	}
}

Promise.all attend toutes les réponses, et la liste échoue dès qu’une réponse échoue. N’ajoutez pas de catch qui renverrait un tableau vide, sinon l’appelant confondrait une panne avec une liste sans atelier.

Étape 4, le branchement

Le partenaire se choisit hors de Inscriptions, au moment où l’on crée l’adaptateur. Ajoutez ce bloc à la fin du fichier. Il crée un partenaire simulé, qui renvoie la réponse prévue pour chaque code, le branche, puis vérifie la liste et le refus des réponses invalides. await attend la réponse, et assert.rejects vérifie qu’une promesse échoue.

class PartenaireSimule implements Partenaire {
	#reponses: Record<string, unknown>;

	constructor(reponses: Record<string, unknown>) {
		this.#reponses = reponses;
	}

	async getAvailability(code: string): Promise<unknown> {
		return this.#reponses[code];
	}
}

const partenaire = new PartenaireSimule({
	"atelier-1": { remaining_seats: 3 },
	"atelier-2": { remaining_seats: 0 },
});
const places = new AdaptateurPlaces(partenaire);
const liste = await inscriptions.listerAteliersPartenaire(places);
assert.deepEqual(liste.map(a => a.places), [3, 0]);

for (const reponse of [null, {}, { remaining_seats: "3" }, { remaining_seats: -1 }, { remaining_seats: 1.5 }]) {
	const invalide = new AdaptateurPlaces(new PartenaireSimule({ "atelier-1": reponse }));
	await assert.rejects(() => invalide.disponibles(1));
}

Pour brancher le vrai service, seule la création de l’adaptateur changera. La liste annonce trois places pour Dessin alors que notre catalogue en prévoit deux, car le partenaire publie ses propres disponibilités sans connaître nos réservations.

À vous

Écrivez une classe PartenaireEnPanne dont getAvailability lance l’erreur « Partenaire indisponible ». Ces lignes doivent alors passer, ce qui montre qu’une panne reste visible.

const enPanne = new AdaptateurPlaces(new PartenaireEnPanne());
await assert.rejects(() => inscriptions.listerAteliersPartenaire(enPanne), /Partenaire indisponible/);

La même idée avec une fonction

Places ne contient qu’une méthode, donc un type de fonction peut le remplacer. placesDuPartenaire reçoit le partenaire et renvoie l’adaptateur. La fonction renvoyée garde accès à api après l’appel, grâce à une fermeture.

type Places = (atelierId: number) => Promise<number>;

function placesDuPartenaire(api: Partenaire): Places {
	return async atelierId => {
		const data = await api.getAvailability(`atelier-${atelierId}`);
		if (typeof data !== "object" || data === null ||
			!("remaining_seats" in data)) throw new Error("Format invalide");
		const places = data.remaining_seats;
		if (typeof places !== "number" || !Number.isSafeInteger(places) || places < 0)
			throw new Error("Places invalides");
		return places;
	};
}

Les appels places.disponibles(atelier.id) deviennent places(atelier.id), et chaque new AdaptateurPlaces(...) devient placesDuPartenaire(...). Vous écrirez cette traduction dans presque tous les projets qui appellent une API. Après un fetch, une fonction vérifie le JSON et renvoie un objet qui porte les noms de l’application. La classe redevient utile quand l’adaptateur expose plusieurs méthodes qui partagent le même client, par exemple pour lire les places, réserver et annuler avec le même jeton d’accès.

Pour réviser

La fiche Adapter et Facade résume le pattern sur un schéma, et la fiche Adapter de Refactoring.Guru le reprend avec d’autres exemples. La séance Repository traite le besoin suivant, conserver les réservations après un redémarrage.

Tracer une réservation avec Decorator

Après une réservation réussie, on veut garder une trace de son enregistrement, sans changer le résultat renvoyé à l’appelant. Travaillez dans une copie d’ateliers.ts.

Le problème

La solution directe écrit la trace dans reserver, ou bien après chaque appel.

const mia = inscriptions.reserver(2, "mia@example.test");
traces.push(`Réservation ${mia.id} enregistrée`);

Dans reserver, la trace s’ajoute aux règles d’inscription, et chaque besoin technique suivant, comme mesurer une durée ou réessayer après une panne, grossira encore la méthode. Après chaque appel, la même ligne se répète partout, et un oubli passe inaperçu.

Decorator place une enveloppe autour de l’objet. Cette enveloppe respecte le même contrat que lui, transmet chaque appel et ajoute son comportement autour. L’appelant l’utilise comme l’objet d’origine, sans voir la différence.

La refacto pas à pas

Étape 1, le contrat

Ajoutez cette interface au-dessus de la classe Inscriptions. Elle décrit la seule opération que l’enveloppe expose. Inscriptions la respecte déjà, puisque sa méthode reserver a la même signature, et TypeScript l’accepte sans implements.

interface ServiceReservation {
	reserver(atelierId: number, email: string): Reservation;
}

Étape 2, l’enveloppe

L’enveloppe garde le service reçu dans son constructeur. Elle lui transmet les mêmes arguments, ajoute la trace seulement après un succès et renvoie le même résultat. Ajoutez-la sous l’interface.

class ReservationTracee implements ServiceReservation {
	#service: ServiceReservation;
	readonly traces: string[] = [];

	constructor(service: ServiceReservation) {
		this.#service = service;
	}

	reserver(atelierId: number, email: string): Reservation {
		const reservation = this.#service.reserver(atelierId, email);
		this.traces.push(`Réservation ${reservation.id} enregistrée`);
		return reservation;
	}
}

Étape 3, le code qui utilise le contrat

Cette fonction représente le formulaire d’inscription. Elle dépend du contrat, et non d’une classe, donc elle accepte aussi bien inscriptions que sa version tracée. Ajoutez-la sous l’enveloppe.

function envoyerFormulaire(service: ServiceReservation, atelierId: number, email: string): string {
	const reservation = service.reserver(atelierId, email);
	return `Réservation ${reservation.id} confirmée`;
}

Vérifiez qu’elle fonctionne déjà avec inscriptions, en ajoutant cette ligne après les assertions du départ. L’atelier Dessin est complet à cet endroit.

assert.throws(() => envoyerFormulaire(inscriptions, 1, "noe@example.test"), /Atelier complet/);

Étape 4, le branchement

Le code qui assemble l’application place l’enveloppe autour de inscriptions, puis la donne au formulaire. Ajoutez ce scénario à la suite. Les identifiants 1 à 3 sont déjà pris, et l’atelier Photo a encore une place.

const tracee = new ReservationTracee(inscriptions);
assert.equal(envoyerFormulaire(tracee, 2, "mia@example.test"), "Réservation 4 confirmée");
assert.deepEqual(tracee.traces, ["Réservation 4 enregistrée"]);
assert.throws(() => envoyerFormulaire(tracee, 2, "noa@example.test"), /Atelier complet/);
assert.equal(tracee.traces.length, 1);

Le second appel est refusé, et l’enveloppe n’ajoute aucune trace, car l’erreur interrompt sa méthode avant la ligne qui écrit la trace.

À vous

Écrivez une enveloppe ReservationComptee qui compte chaque appel à reserver, réussi ou refusé, puis placez-la autour de tracee. Après l’appel refusé envoyerFormulaire(comptee, 2, "noa@example.test"), comptee.tentatives doit valoir 1, et tracee.traces ne doit toujours contenir qu’une ligne.

La même idée avec une fonction

Une enveloppe peut aussi entourer une fonction. avecTrace reçoit une fonction de réservation et renvoie une nouvelle fonction de même signature, qui appelle la première puis ajoute la trace.

type Reserver = (atelierId: number, email: string) => Reservation;

function avecTrace(reserver: Reserver, traces: string[]): Reserver {
	return (atelierId, email) => {
		const reservation = reserver(atelierId, email);
		traces.push(`Réservation ${reservation.id} enregistrée`);
		return reservation;
	};
}

const traces: string[] = [];
const reserverTracee = avecTrace((id, email) => inscriptions.reserver(id, email), traces);
const mia = reserverTracee(2, "mia@example.test");
assert.deepEqual(traces, [`Réservation ${mia.id} enregistrée`]);

La fonction fléchée (id, email) => inscriptions.reserver(id, email) a son importance. Passée seule, inscriptions.reserver serait appelée sans son objet, this vaudrait alors undefined, et l’appel échouerait avec une TypeError. L’écriture inscriptions.reserver.bind(inscriptions) produit le même résultat que la fonction fléchée.

On retrouve cette enveloppe dans le code web. Une fonction qui entoure fetch pour ajouter un en-tête garde la signature de fetch. Les middlewares de Hono exécutent leur code avant et après await next(), qui appelle la route sans qu’elle le sache. Les décorateurs de TypeScript, écrits @Component dans Angular, portent le même nom mais servent surtout à annoter une classe.

Pour réviser

La fiche Decorator résume le pattern sur un schéma, et la fiche de Refactoring.Guru le présente avec d’autres exemples. Decorator conserve le contrat de l’objet entouré, alors qu’un Adapter traduit un contrat différent.

Préparer la persistance avec Repository

Pour retrouver les réservations après un redémarrage, l’application devra les enregistrer dans un stockage durable, par exemple une base de données. Cette séance prépare ce changement. Elle place les réservations derrière un contrat, puis vérifie les échanges avec un stockage simulé en mémoire. Travaillez dans une copie d’ateliers.ts.

Le problème

Dans le départ, Inscriptions garde elle-même le tableau #reservations et le compteur #prochainId. Les méthodes listerAteliers, reserver et annuler lisent et modifient ce tableau directement. Pour passer à une base de données, il faudrait réécrire chacune d’elles, et les requêtes vers le stockage se mêleraient aux règles de réservation.

Un Repository présente les objets enregistrés comme une collection, derrière un contrat. Le code des règles lit et ajoute des réservations sans savoir où elles sont rangées. Ce pattern vient du catalogue de Martin Fowler, et non du livre du Gang of Four.

La refacto pas à pas

Étape 1, le contrat

Ajoutez ce contrat après le type Reservation.

interface Reservations {
	lister(): Promise<Reservation[]>;
	ajouter(nouvelle: Omit<Reservation, "id">): Promise<Reservation>;
	retirer(id: number): Promise<Reservation>;
}

Les méthodes renvoient des promesses, car un vrai stockage répond plus tard. En cas de panne, la promesse est rejetée et l’appelant reçoit l’erreur quand il attend le résultat. Omit<Reservation, "id"> décrit une réservation sans identifiant, puisque c’est le stockage qui l’attribue.

Étape 2, la classe qui le respecte

ReservationsEnMemoire reprend le tableau et le compteur, qui quittent Inscriptions. Ajoutez-la avant cette classe.

class ReservationsEnMemoire implements Reservations {
	#reservations: Reservation[] = [];
	#prochainId = 1;

	async lister(): Promise<Reservation[]> {
		return this.#reservations.map(r => ({ ...r }));
	}

	async ajouter(nouvelle: Omit<Reservation, "id">): Promise<Reservation> {
		const reservation = { ...nouvelle, id: this.#prochainId++ };
		this.#reservations.push(reservation);
		return { ...reservation };
	}

	async retirer(id: number): Promise<Reservation> {
		const index = this.#reservations.findIndex(r => r.id === id);
		if (index === -1) throw new Error("Réservation inconnue");
		const reservation = this.#reservations[index];
		this.#reservations.splice(index, 1);
		return { ...reservation };
	}
}

La classe renvoie des copies, pour qu’un appelant ne puisse pas modifier une réservation stockée à travers le résultat reçu. La différence entre deux objets et deux références explique cette précaution. Le tableau perd ses données à l’arrêt du programme, donc cette classe sert seulement à vérifier les échanges.

Étape 3, le code qui utilise le contrat

Remplacez la classe Inscriptions par celle-ci. Elle reçoit le stockage dans son constructeur, ses trois méthodes deviennent asynchrones et le getter reservations disparaît. Les assertions de départ ne fonctionnent plus à cette étape, et l’étape 4 les remplace.

class Inscriptions {
	#ateliers: Atelier[];
	#stockage: Reservations;
	#notifications: string[] = [];

	constructor(ateliers: Atelier[], stockage: Reservations) {
		this.#ateliers = ateliers;
		this.#stockage = stockage;
	}

	get notifications(): string[] {
		return [...this.#notifications];
	}

	async listerAteliers() {
		const existantes = await this.#stockage.lister();
		return this.#ateliers.map(atelier => ({
			...atelier,
			places: atelier.capacite - existantes.filter(r => r.atelierId === atelier.id).length,
		}));
	}

	async reserver(atelierId: number, email: string): Promise<Reservation> {
		const atelier = this.#ateliers.find(a => a.id === atelierId);
		if (!atelier) throw new Error("Atelier inconnu");
		const adresse = email.trim().toLowerCase();
		if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(adresse)) throw new Error("Courriel invalide");
		const existantes = await this.#stockage.lister();
		if (existantes.some(r => r.atelierId === atelierId && r.email === adresse))
			throw new Error("Déjà inscrit");
		if (existantes.filter(r => r.atelierId === atelierId).length >= atelier.capacite)
			throw new Error("Atelier complet");
		const reservation = await this.#stockage.ajouter({
			atelierId, email: adresse, montantCentimes: atelier.prixCentimes,
		});
		this.#notifications.push(`Confirmation ${reservation.id} pour ${adresse}`);
		return { ...reservation };
	}

	async annuler(reservationId: number): Promise<void> {
		const reservation = await this.#stockage.retirer(reservationId);
		this.#notifications.push(`Annulation ${reservation.id} pour ${reservation.email}`);
	}
}

La confirmation est ajoutée seulement après await this.#stockage.ajouter(...). Si le stockage refuse l’enregistrement, l’erreur interrompt la méthode avant cette ligne.

Étape 4, le branchement

Le stockage se crée hors de Inscriptions, puis passe à son constructeur. Remplacez tout ce qui suit la déclaration de ateliers par ce scénario, qui attend chaque résultat avec await.

const memoire = new ReservationsEnMemoire();
const inscriptions = new Inscriptions(ateliers, memoire);

await assert.rejects(() => inscriptions.reserver(999, "lea@example.test"), /Atelier inconnu/);
const lea = await inscriptions.reserver(1, " Lea@example.test ");
assert.equal(lea.id, 1);
await assert.rejects(() => inscriptions.reserver(1, "LEA@example.test"), /Déjà inscrit/);
const sam = await inscriptions.reserver(1, "sam@example.test");
await assert.rejects(() => inscriptions.reserver(1, "zoe@example.test"), /Atelier complet/);
assert.deepEqual((await inscriptions.listerAteliers()).map(a => a.places), [0, 1]);

await inscriptions.annuler(lea.id);
await assert.rejects(() => inscriptions.annuler(lea.id), /Réservation inconnue/);
assert.deepEqual((await memoire.lister()).map(r => r.id), [sam.id]);
console.log("Vérifications réussies.");

assert.rejects vérifie qu’une promesse échoue, comme assert.throws le fait pour un appel synchrone. Le jour où un vrai stockage existera, seule la ligne qui crée memoire changera.

À vous

Écrivez une classe StockageRefusant qui implémente Reservations et reçoit memoire dans son constructeur. Sa méthode lister lit les réservations de memoire, ajouter lance l’erreur « Enregistrement refusé » et retirer lance « Retrait refusé ». Ajoutez ensuite ces lignes à la fin du fichier, elles doivent passer.

const enPanne = new Inscriptions(ateliers, new StockageRefusant(memoire));
await assert.rejects(() => enPanne.reserver(2, "mia@example.test"), /Enregistrement refusé/);
assert.deepEqual(enPanne.notifications, []);
assert.equal((await memoire.lister()).length, 1);

Un enregistrement refusé ne produit donc aucune confirmation, et la mémoire garde sa réservation.

La même idée avec une fonction

Le contrat a trois méthodes, il reste donc un objet. Cet objet n’a pourtant pas besoin d’une classe. Une fonction peut le créer avec des accolades et garder le tableau et le compteur dans ses variables locales, que le code extérieur ne peut pas atteindre.

function creerReservationsEnMemoire(): Reservations {
	const reservations: Reservation[] = [];
	let prochainId = 1;
	return {
		async lister() {
			return reservations.map(r => ({ ...r }));
		},
		async ajouter(nouvelle) {
			const reservation = { ...nouvelle, id: prochainId++ };
			reservations.push(reservation);
			return { ...reservation };
		},
		async retirer(id) {
			const index = reservations.findIndex(r => r.id === id);
			if (index === -1) throw new Error("Réservation inconnue");
			const reservation = reservations[index];
			reservations.splice(index, 1);
			return { ...reservation };
		},
	};
}

new ReservationsEnMemoire() devient creerReservationsEnMemoire(), et Inscriptions ne change pas, puisqu’elle dépend seulement du contrat. Dans un serveur Node, on retrouve ce découpage dans le module qui regroupe les requêtes vers la base de données, en SQL, avec Prisma ou avec Drizzle. Les routes appellent lister ou ajouter sans connaître les tables, et les tests leur fournissent une version en mémoire. NestJS écrit plutôt ces objets en classes, parce que son injection de dépendances les crée à partir de leur constructeur.

Pour réviser

Cette séance ne rend pas encore les réservations durables. Pour valider un vrai stockage, enregistrez une réservation, arrêtez le programme, puis retrouvez-la au redémarrage, sans que le compteur réattribue un identifiant déjà utilisé. Un Adapter peut traduire les appels vers le fournisseur de ce stockage. La définition de Martin Fowler tient en une page et complète cette séance.

Faire réagir les vues avec Observer

Après chaque confirmation ou annulation, une vue des places disponibles doit se mettre à jour. Cette vue est la partie de l’interface qui affiche les places restantes. D’autres réactions viendront ensuite, comme l’envoi d’un courriel. Quand l’utilisateur ferme la vue, elle ne doit plus rien recevoir.

Travaillez dans une copie d’ateliers.ts. Dans cette séance, un petit objet simule la vue, sans interface réelle.

Le problème

La solution directe appelle chaque réaction dans reserver, juste après l’enregistrement, puis fait de même dans annuler.

this.#notifications.push(`Confirmation ${reservation.id} pour ${adresse}`);
this.#vue.actualiser(reservation);
this.#courriel.envoyer(reservation);

Inscriptions doit alors connaître chaque réaction et la recevoir dans son constructeur. Ajouter une réaction modifie la classe à plusieurs endroits, et une vue fermée n’a aucun moyen de se retirer.

Avec Observer, Inscriptions garde une liste d’abonnés, appelés observateurs, et les prévient tous après chaque changement. Elle ne connaît d’eux que leur contrat, et chacun peut s’abonner ou se retirer à tout moment.

La refacto pas à pas

Étape 1, le contrat

Ajoutez ce type et cette interface au-dessus de la classe Inscriptions. Un événement décrit un changement qui a déjà eu lieu, et tout observateur sait le recevoir.

type Evenement = { type: "confirmation" | "annulation"; reservation: Reservation };

interface Observateur {
	recevoir(evenement: Evenement): void;
}

Étape 2, les classes qui le respectent

La vue devient un observateur. Pour la simulation, elle note le type de chaque changement reçu. Ajoutez-la sous l’interface.

class VueReservations implements Observateur {
	changements: string[] = [];

	recevoir(evenement: Evenement): void {
		this.changements.push(evenement.type);
	}
}

Étape 3, le code qui utilise le contrat

Dans la classe Inscriptions, ajoutez les lignes marquées +. abonner et desabonner gèrent la liste, #notifier prévient chaque observateur, et reserver comme annuler appellent #notifier après leur travail.

 class Inscriptions {
 	// Les champs, le constructeur et les autres méthodes ne changent pas.
+	#observateurs = new Set<Observateur>();
+
+	abonner(observateur: Observateur): void {
+		this.#observateurs.add(observateur);
+	}
+
+	desabonner(observateur: Observateur): void {
+		this.#observateurs.delete(observateur);
+	}
+
+	#notifier(type: Evenement["type"], reservation: Reservation): void {
+		const evenement = { type, reservation: { ...reservation } };
+		for (const observateur of [...this.#observateurs]) {
+			try {
+				observateur.recevoir(evenement);
+			} catch (erreur) {
+				console.error("Réaction en échec :", erreur);
+			}
+		}
+	}
 
 	reserver(atelierId: number, email: string): Reservation {
 		// Les vérifications et l'enregistrement ne changent pas.
 		this.#notifications.push(`Confirmation ${reservation.id} pour ${adresse}`);
+		this.#notifier("confirmation", reservation);
 		return { ...reservation };
 	}
 
 	annuler(reservationId: number): void {
 		// La recherche et le retrait ne changent pas.
 		this.#notifications.push(`Annulation ${reservation.id} pour ${reservation.email}`);
+		this.#notifier("annulation", reservation);
 	}
 }

La boucle parcourt une copie de la liste, si bien qu’un désabonnement pendant l’envoi ne la perturbe pas. Le try affiche l’erreur d’un observateur sans bloquer les suivants ni annuler la réservation déjà enregistrée.

Étape 4, le branchement

Le code qui crée la vue l’abonne, puis la retire quand la vue se ferme. Ajoutez ce scénario après les assertions du départ. L’atelier Photo a encore une place à cet endroit.

const vue = new VueReservations();
inscriptions.abonner(vue);
const mia = inscriptions.reserver(2, "mia@example.test");
assert.deepEqual(vue.changements, ["confirmation"]);

inscriptions.desabonner(vue);
inscriptions.annuler(mia.id);
assert.deepEqual(vue.changements, ["confirmation"]);
assert.equal(inscriptions.notifications.at(-1), `Annulation ${mia.id} pour mia@example.test`);

Le diagramme suivant montre l’ordre de ces appels. La vue ne reçoit pas l’annulation, car elle s’est désabonnée avant. Supprimer une vue du HTML ne suffirait pas, puisque #observateurs garde une référence vers elle.

sequenceDiagram accTitle: La vue reçoit les changements jusqu'à son désabonnement accDescr: La vue s'abonne auprès de l'objet inscriptions. Après l'enregistrement de la réservation, le sujet prévient la vue. Après le désabonnement, l'annulation ne la prévient plus. participant Vue participant Sujet as inscriptions Vue->>Sujet: abonner(vue) Sujet->>Sujet: reserver() enregistre la réservation Sujet->>Vue: recevoir(confirmation) Vue->>Sujet: desabonner(vue) Sujet->>Sujet: annuler() retire la réservation Note over Vue: La vue ne reçoit plus l'annulation

À vous

Ajoutez un observateur CompteurAnnulations qui compte les annulations, sans modifier la classe Inscriptions. Après une réservation puis son annulation, son compteur doit valoir 1.

La même idée avec des fonctions

Dans le code web, un observateur est le plus souvent une simple fonction. L’interface devient alors un type de fonction, et abonner peut renvoyer la fonction qui retire l’abonnement, ce qui remplace desabonner.

type Observateur = (evenement: Evenement) => void;

class Inscriptions {
	// Les autres membres ne changent pas. La méthode desabonner disparaît.

	abonner(observateur: Observateur): () => void {
		this.#observateurs.add(observateur);
		return () => {
			this.#observateurs.delete(observateur);
		};
	}
}

Dans #notifier, l’appel devient observateur(evenement). Le code qui s’abonne garde la fonction de retrait qu’il reçoit.

const changements: string[] = [];
const desabonner = inscriptions.abonner(evenement => changements.push(evenement.type));
const mia = inscriptions.reserver(2, "mia@example.test");
desabonner();
inscriptions.annuler(mia.id);
assert.deepEqual(changements, ["confirmation"]);

Les stores de Svelte, la méthode subscribe de Redux et le store de Frameworks 2 suivent cette forme. Le DOM garde l’autre forme, car removeEventListener doit recevoir la même fonction que addEventListener. On peut aussi transmettre un AbortSignal à addEventListener, puis appeler abort() sur son contrôleur.

Pour réviser

La fiche Observer résume le pattern sur un schéma, et la fiche de Refactoring.Guru le présente avec d’autres exemples. Le chapitre Frameworks 5 rattache la durée d’un abonnement à celle de la vue qui l’a créé.

Frameworks 1, qui appelle le code de l’application

L’application d’ateliers sait déjà réserver une place et refuser une demande invalide. Pour l’utiliser dans un navigateur, il reste à relier ces opérations à une interface. Qui appelle le code de réservation après un clic ? Comment le nombre de places affiché suit-il les réservations ? Que faut-il arrêter lorsqu’une vue disparaît ?

Nous allons construire un petit framework d’environ 130 lignes en sept chapitres. Chaque étape corrige une limite de la précédente à l’aide des patterns du cours. On retrouve ainsi des mécanismes présents dans React, Vue, Svelte ou Angular.

  1. Dans ce chapitre, le navigateur déclenche les actions de la personne et une première fonction monter installe l’interface. On introduit ainsi l’inversion de contrôle.
  2. Un état observé et un rendu naïf relie les changements de données à l’affichage avec un store fondé sur Observer. La mise à jour par innerHTML révèle ensuite trois limites.
  3. Composants, fonctions ou classes sépare les parties de l’interface et compare deux façons de leur donner un cycle de vie, avec les Web Components et un rapprochement avec Template Method.
  4. Des signaux qui s’abonnent seuls évite de déclarer chaque abonnement à la main. Le mécanisme observe les données lues pendant un calcul.
  5. Monter et nettoyer associe les abonnements à la durée de vie de la vue afin de les retirer au bon moment.
  6. Fournir les services sépare les composants du choix des objets qu’ils utilisent grâce à l’injection de dépendances.
  7. Comparer avec les frameworks réels situe le DOM virtuel et la compilation par rapport aux mécanismes construits.

Les chapitres font évoluer les mêmes fichiers. Le corrigé final rassemble le framework obtenu à la fin du chapitre 6. Le dossier mini-framework contient aussi l’application complète et ses vérifications.

Ce que dit la définition

En 1988, Ralph Johnson et Brian Foote décrivent les frameworks orientés objet dans l’article Designing Reusable Classes, publié dans le Journal of Object-Oriented Programming. Ralph Johnson est aussi l’un des quatre auteurs du catalogue GoF. Voici leur définition, traduite :

Une caractéristique importante d’un framework est que les méthodes définies par l’utilisateur pour l’adapter sont souvent appelées depuis le framework lui-même, et non depuis le code de l’application. Le framework joue souvent le rôle du programme principal : il coordonne et ordonne l’activité de l’application. Cette inversion du contrôle donne aux frameworks le pouvoir de servir de squelettes extensibles.

En 2005, Martin Fowler reprend cette citation dans son article InversionOfControl. Il y écrit que l’inversion de contrôle est ce qui distingue un framework d’une bibliothèque. Il cite aussi son surnom, le « principe d’Hollywood », d’après la phrase qu’on prête aux studios : « Ne nous appelez pas, nous vous appellerons. »

En clair

Une bibliothèque fournit des fonctions que l’application appelle pour effectuer une tâche. Le code de l’application choisit quand demander cette tâche et comment utiliser son résultat. Par exemple, dans inscriptions.reserver(1, "lea@example.test"), il demande une réservation, puis poursuit son traitement. L’exemple est synchrone, et une bibliothèque peut aussi être asynchrone. La différence tient à qui décide du moment de l’appel.

Un framework organise une partie de l’exécution et appelle le code que l’application lui fournit aux moments prévus. Vous écrivez par exemple comment construire une vue, et le framework décide quand appeler cette fonction. L’inversion de contrôle, souvent abrégée IoC, désigne ce changement de responsable. Pour la partie concernée, l’application fournit une réaction au lieu de déclencher elle-même chaque appel. Elle garde toutefois la responsabilité de ses règles et des opérations qu’elle demande depuis cette réaction.

Le repérer dans le code

Suivez l’appel d’une fonction de l’application. Si votre code la transmet au navigateur ou au framework, qui la déclenche ensuite selon son propre cycle, l’appel est inversé. Ce transfert du moment de l’appel caractérise l’inversion de contrôle, même quand l’ordre des instructions reste facile à suivre.

Avant, le terminal pose les questions dans son ordre

Martin Fowler compare deux versions d’un même programme pour montrer qui pilote l’échange. On peut faire cette comparaison avec les ateliers, sans modifier les règles de réservation. Dans une copie d’ateliers.ts, remplacez l’import d’assert par celui de readline. Conservez le catalogue et la création de l’objet d’inscription, puis remplacez les assertions et les console.log de la fin par ces lignes.

import { createInterface } from "node:readline/promises";

// Les types, la classe Inscriptions et le catalogue ne changent pas.

const terminal = createInterface({ input: process.stdin, output: process.stdout });
const email = await terminal.question("Courriel : ");
const atelierId = Number(await terminal.question("Numéro d'atelier : "));
console.log(inscriptions.reserver(atelierId, email));
terminal.close();

Exécutez node ateliers.ts et répondez aux deux questions avec un courriel valide et un identifiant d’atelier. La réservation s’affiche ensuite dans le terminal. Dans cette version, le programme fixe l’ordre de l’échange. Il attend le courriel avant de demander l’atelier, puis attend l’identifiant avant de réserver. La personne ne peut pas inverser ces deux étapes sans que l’on change le programme.

sequenceDiagram accTitle: Le programme du terminal pilote l'échange accDescr: Le programme pose la question du courriel au terminal et attend la réponse. Il pose ensuite la question de l'atelier, attend la réponse, puis appelle reserver. participant Programme participant Terminal Programme->>Terminal: question("Courriel") Terminal-->>Programme: lea@example.test Programme->>Terminal: question("Numéro d'atelier") Terminal-->>Programme: 1 Programme->>Programme: inscriptions.reserver(1, email)

Après, le navigateur appelle vos fonctions

Dans le formulaire web que l’on veut construire, les champs et les boutons restent disponibles en même temps. La personne peut saisir son courriel, choisir un atelier, puis revenir corriger l’adresse. Au lieu d’attendre chaque réponse dans une suite d’instructions, l’application enregistre une fonction à appeler lors du clic. Le navigateur la déclenche lorsque cet événement survient.

bouton.addEventListener("click", () => {
  inscriptions.reserver(atelier.id, champ.value);
});

L’appel à addEventListener illustre déjà une inversion de contrôle à petite échelle. La fonction passée en second argument n’est pas exécutée au moment de l’inscription. Le navigateur conserve l’écouteur et l’appelle lorsque le bouton reçoit un clic. L’application n’a donc pas à écrire une boucle qui surveille ce bouton. Le navigateur prend en charge le déclenchement des appels à travers sa boucle d’événements.

sequenceDiagram accTitle: Le navigateur pilote l'échange accDescr: L'application inscrit sa réaction auprès du navigateur puis rend la main. Quand la personne clique, le navigateur appelle la réaction, qui appelle reserver. participant Application participant Navigateur Application->>Navigateur: addEventListener("click", réaction) Note over Navigateur: la personne clique Navigateur->>Application: réaction() Application->>Application: inscriptions.reserver(1, email)

Notre framework appellera aussi le code de l’application pour construire l’affichage, le mettre à jour et libérer ses ressources. Le retrait d’un élément ne suffit pas à arrêter tout le code qui lui est associé.

Préparer le projet

Le premier programme web réutilise les règles du terminal, mais son point d’entrée sera une page. Comme les navigateurs n’exécutent pas directement TypeScript, Vite transforme les fichiers .ts et fournit un serveur de développement. Créez le projet dans un nouveau dossier de travail :

npm create vite@latest ateliers-ui -- --template vanilla-ts
cd ateliers-ui
npm install

Le modèle vanilla-ts crée une page index.html, qui contient un élément <div id="app"> et charge src/main.ts. Vous remplacerez ce fichier plus bas. Les autres fichiers de démonstration de src/ ne serviront pas, et vous pouvez les supprimer.

Copiez ensuite inscriptions.ts du corrigé dans src/. Il reprend les règles de réservation d’ateliers.ts, sans le catalogue ni les assertions. Il ajoute le type AtelierAvecPlaces, qui décrit un atelier accompagné de son nombre de places libres et que renvoie listerAteliers.

Ce fichier est un module. Le mot export placé devant ses types et sa classe permet aux autres fichiers de les importer. Les champs privés comme #reservations restent en revanche inaccessibles hors de la classe. L’interface passera donc par les méthodes publiques d’Inscriptions, et les règles de capacité et de doublon resteront dans la classe. Le guide des modules de MDN détaille import et export.

Écrire la première fonction du framework

Créez src/framework.ts pour y placer le code qui installe une interface, sans connaître les ateliers ni les réservations. Cette première version ne fait que construire un élément, l’ajouter à la page et fournir une façon de le retirer. Le type HTMLElement désigne l’objet JavaScript qui représente un élément HTML.

export type Composant = () => HTMLElement;

export function monter(racine: HTMLElement, composant: Composant): () => void {
  const element = composant();
  racine.append(element);
  return () => element.remove();
}

Un composant est ici une fonction qui construit un morceau d’interface et renvoie son élément HTML. La fonction monter reçoit cette fonction de construction et la racine, c’est-à-dire l’élément de la page qui accueillera son résultat. Elle appelle le composant, puis insère l’élément obtenu dans la page. Elle renvoie enfin une fonction qui pourra retirer cet élément. Cette fonction prépare le démontage, pour l’instant limité au retrait du DOM, l’arbre d’éléments qui représente la page. Le type de retour () => void indique que l’appel à la fonction de montage fournit une autre fonction, sans argument ni valeur de retour.

En recevant une fonction, monter choisit le moment de construction. Si on lui fournissait seulement un élément déjà construit, le composant se serait exécuté avant l’appel au framework. Dans cette première version, monter l’appelle immédiatement. Au chapitre 5, ce même point d’appel permettra aussi de recueillir les opérations de nettoyage créées pendant la construction.

Remplacez ensuite le contenu de src/main.ts.

import { Inscriptions } from "./inscriptions.ts";
import { monter } from "./framework.ts";

const inscriptions = new Inscriptions([
  { id: 1, titre: "Dessin", capacite: 2, prixCentimes: 2000 },
  { id: 2, titre: "Photo", capacite: 1, prixCentimes: 3000 },
]);

function PageAteliers(): HTMLElement {
  const page = document.createElement("main");
  const champ = document.createElement("input");
  champ.type = "email";
  const etiquette = document.createElement("label");
  etiquette.append("Courriel ", champ);
  const statut = document.createElement("p");
  statut.setAttribute("role", "status");
  const liste = document.createElement("ul");

  for (const atelier of inscriptions.listerAteliers()) {
    const bouton = document.createElement("button");
    bouton.textContent = "Réserver";
    bouton.addEventListener("click", () => {
      try {
        const reservation = inscriptions.reserver(atelier.id, champ.value);
        statut.textContent = `Réservation ${reservation.id} confirmée pour ${reservation.email}`;
      } catch (erreur) {
        statut.textContent = erreur instanceof Error ? erreur.message : String(erreur);
      }
    });
    const ligne = document.createElement("li");
    ligne.append(`${atelier.titre}, places libres : ${atelier.places} `, bouton);
    liste.append(ligne);
  }

  page.append(etiquette, liste, statut);
  return page;
}

monter(document.querySelector<HTMLElement>("#app")!, PageAteliers);

Les imports se terminent par .ts, comme dans le reste du cours. Vite les accepte, et Node.js pourra exécuter les mêmes fichiers dans les tests des chapitres suivants. Le nom PageAteliers commence par une majuscule, une convention des frameworks pour distinguer un composant d’une fonction ordinaire. L’attribut role="status" demande aux lecteurs d’écran d’annoncer les changements du message.

Le try regroupe l’appel à reserver et la préparation du message de confirmation. Si la réservation est refusée, le traitement passe dans le bloc d’erreur et affiche le motif, par exemple « Atelier complet ». La règle de capacité reste dans Inscriptions, tandis que l’interface traduit le résultat en message pour la personne.

Vérifier

Lancez npm run dev, puis ouvrez l’adresse affichée, en général http://localhost:5173. Saisissez lea@example.test et cliquez sur le bouton de l’atelier Dessin. Au premier essai, le message doit confirmer la réservation 1. Cliquez à nouveau avec la même adresse pour observer le refus d’une double inscription. Le message change, mais la réservation précédente reste enregistrée.

Après la première réservation, la ligne affiche pourtant toujours « places libres : 2 ». Le calcul des places est juste. Le décalage vient de PageAteliers, qui a lu les données une seule fois, au montage, puis créé un texte qui ne change plus. La réservation a modifié les données internes, sans déclencher de nouvelle lecture pour l’affichage. Il manque donc un lien entre le changement de données et la mise à jour de la vue. Le chapitre 2 construit ce lien.

Le trajet d’un clic

Le schéma suit un clic sur Réserver jusqu’au nombre de places actualisé. La réservation fonctionne déjà, mais le lien entre l’état et l’affichage reste à construire.

flowchart TB accTitle: Du clic au nouvel affichage accDescr: Le clic déclenche une action. Les règles de réservation acceptent ou refusent la demande. Le résultat met à jour l'état de l'interface, qui prévient la vue, et la vue actualise le DOM. Clic[Clic dans le DOM] --> Action[Action réserver] Action --> Regles[Règles de réservation] Regles -->|résultat| Etat[État de l'interface] Etat -->|notification| Vue[Rendu de la vue] Vue -->|actualise| DOM[DOM]

Chaque étape a un rôle distinct dans ce trajet.

ÉlémentResponsabilitéChapitre
Événement DOMSignaler le clic de la personne.1
ActionDemander une réservation et traiter son résultat.1, puis 6
RèglesVérifier la capacité, le courriel et les doublons dans Inscriptions.Point de départ
État de l’interfaceConserver les informations nécessaires à l’affichage.2
NotificationsPrévenir les vues d’un changement d’état.2, puis 4
RenduMettre l’affichage en accord avec l’état.2, 3 et 4
NettoyageRetirer les abonnements d’une vue démontée.3, puis 5

Désactiver le bouton d’un atelier complet améliore l’interface, mais la règle de capacité reste dans Inscriptions. Un autre appelant, comme un import de fichier ou une autre page, pourrait demander une réservation sans passer par ce bouton.

Nuance, une frontière qui dépend du point de vue

La limite entre bibliothèque et framework dépend de la partie du programme que l’on observe. React se présente comme une bibliothèque, et votre code l’appelle bien pour démarrer le rendu. Pourtant, son moteur appelle ensuite vos composants pendant les phases de rendu et de commit. Pour cette partie de l’exécution, React se comporte comme un framework, tandis que d’autres choix, comme le routage, restent à votre charge.

L’application garde ses décisions, mais confie certains déclenchements au framework. Suivre l’exécution demande alors de connaître ses points d’appel. Une erreur dans un composant peut apparaître dans une pile qui traverse le framework, et l’ordre des mises à jour ne se déduit plus du seul fichier du composant.

Frameworks 2, un état observé et un rendu naïf

À la fin du chapitre 1, la réservation réussit, mais la page affiche toujours le même nombre de places libres. PageAteliers n’a lu les ateliers qu’au montage. Nous allons ajouter un objet qui conserve l’état de l’interface et prévient les vues à chaque modification, puis remplacer toute la page pour afficher ce nouvel état.

L’état de l’interface

L’état de l’interface regroupe les données dont le code a besoin pour construire l’affichage à un instant donné. Ici, il contient la liste des ateliers avec leurs places libres, le courriel en cours de saisie et le dernier message à afficher. À partir de ces valeurs, une fonction peut déterminer le texte de chaque ligne et si son bouton doit être désactivé.

Cet état ne remplace pas les données conservées par Inscriptions. La classe garde les réservations et vérifie les règles, notamment la capacité de chaque atelier. L’interface en conserve une copie préparée pour l’affichage, avec le nombre de places déjà calculé. Elle y ajoute des informations propres à l’écran, comme le texte du champ. Quand une réservation réussit, l’interface relit les ateliers auprès d’Inscriptions et remplace sa copie. Elle affiche ainsi le résultat des règles au lieu de tenir un second compte des réservations.

Un store est le sujet d’un Observer

Un store est un objet qui conserve l’état de l’application et prévient les vues de ses changements. Le terme vient de l’architecture Flux, présentée par Facebook en 2014, et la bibliothèque Redux l’a diffusé à partir de 2015. Notre exemple en garde seulement le stockage et les notifications.

Le store que l’on va écrire permet de lire l’état, de le remplacer et de s’abonner à ses changements. Il reprend l’organisation de la séance Observer. Le sujet est le store, et ses observateurs sont les fonctions qui mettent la page à jour. Au lieu de transmettre un événement comme « confirmation », chaque notification leur fournit l’état complet. Tous les abonnés le reçoivent, même si seule une donnée qui ne les concerne pas a changé.

Ajoutez ce type et cette fonction à src/framework.ts, sous monter.

export type Store<E> = {
  lire(): E;
  ecrire(suivant: E): void;
  abonner(abonne: (etat: E) => void): () => void;
};

export function creerStore<E>(initial: E): Store<E> {
  let etat = initial;
  const abonnes = new Set<(etat: E) => void>();
  return {
    lire: () => etat,
    ecrire(suivant) {
      etat = suivant;
      for (const abonne of [...abonnes]) abonne(etat);
    },
    abonner(abonne) {
      abonnes.add(abonne);
      return () => {
        abonnes.delete(abonne);
      };
    },
  };
}

Le paramètre de type E représente la forme de l’état, si bien que le framework peut conserver n’importe quelle valeur sans connaître les ateliers. creerStore est une fabrique : les méthodes renvoyées gardent accès aux variables etat et abonnes après la fin de l’appel. Cette fermeture les protège comme des champs privés. En revanche, lire renvoie l’objet lui-même, sans copie. Si un appelant le modifie directement, le store ne prévient personne, donc notre application passe toujours par ecrire.

ecrire parcourt une copie des abonnés, [...abonnes]. Ainsi, une vue abonnée pendant une notification ne reçoit pas cette même notification.

abonner ajoute une fonction à la liste des destinataires, mais ne l’appelle pas immédiatement. Il faut donc lire l’état pour afficher la vue une première fois. La fonction renvoyée par abonner permet ensuite de se désabonner. Le code qui crée la vue doit la conserver pour interrompre les notifications quand cette vue disparaît.

Pour vérifier ce comportement sans navigateur, créez tests/store.test.ts à la racine du projet, à côté de src/.

import assert from "node:assert/strict";
import { creerStore } from "../src/framework.ts";

const store = creerStore({ places: 2 });
const recues: number[] = [];
const desabonner = store.abonner(etat => recues.push(etat.places));

store.ecrire({ places: 1 });
desabonner();
store.ecrire({ places: 0 });

assert.deepEqual(recues, [1]);
assert.equal(store.lire().places, 0);
console.log("Vérifications réussies.");

Exécutez node tests/store.test.ts. Le terminal doit afficher « Vérifications réussies. ». L’abonné a reçu la valeur 1, mais pas la valeur 0 écrite après son désabonnement, que le store conserve bien. Node.js exécute ce test sans navigateur, car framework.ts ne touche à aucun élément de page au chargement.

Redessiner toute la page à chaque changement

Le rendu produit l’affichage à partir de l’état. Une fonction reçoit les données et décrit la page correspondante. Recalculer cette description après chaque écriture dans le store évite de prévoir une modification pour chaque événement possible.

La façon la plus directe d’appliquer cette idée consiste à écrire la page sous forme de texte HTML, puis à le donner à innerHTML. Ajoutez cette fonction au framework.

export function monterGabarit<E>(
  racine: HTMLElement,
  store: Store<E>,
  gabarit: (etat: E) => string,
): () => void {
  const afficher = (etat: E) => {
    racine.innerHTML = gabarit(etat);
  };
  afficher(store.lire());
  return store.abonner(afficher);
}

On appelle ici gabarit une fonction qui reçoit l’état et renvoie le texte HTML correspondant. monterGabarit affiche d’abord l’état actuel, puis abonne sa fonction d’affichage aux écritures suivantes. Votre code fournit le gabarit, mais le framework décide quand l’appeler, selon l’inversion de contrôle du chapitre 1. La fonction renvoyée interrompt cet abonnement. Contrairement au démontage du chapitre 1, elle ne retire pas les éléments déjà affichés.

Remplacez src/main.ts par cette version.

import { Inscriptions, type AtelierAvecPlaces } from "./inscriptions.ts";
import { creerStore, monterGabarit } from "./framework.ts";

const inscriptions = new Inscriptions([
  { id: 1, titre: "Dessin", capacite: 2, prixCentimes: 2000 },
  { id: 2, titre: "Photo", capacite: 1, prixCentimes: 3000 },
]);

type Etat = Readonly<{ ateliers: AtelierAvecPlaces[]; email: string; message: string }>;

const store = creerStore<Etat>({
  ateliers: inscriptions.listerAteliers(),
  email: "",
  message: "",
});

function gabarit(etat: Etat): string {
  const lignes = etat.ateliers.map(atelier => {
    const desactive = etat.email === "" || atelier.places === 0;
    return `<li>${atelier.titre}, places libres : ${atelier.places}
      <button data-atelier="${atelier.id}" ${desactive ? "disabled" : ""}>Réserver</button></li>`;
  });
  return `<main>
    <label>Courriel <input type="email" value="${etat.email}"></label>
    <ul>${lignes.join("")}</ul>
    <p role="status">${etat.message}</p>
  </main>`;
}

function reserver(atelierId: number): void {
  const etat = store.lire();
  try {
    const reservation = inscriptions.reserver(atelierId, etat.email);
    store.ecrire({
      ...etat,
      ateliers: inscriptions.listerAteliers(),
      message: `Réservation ${reservation.id} confirmée pour ${reservation.email}`,
    });
  } catch (erreur) {
    store.ecrire({ ...etat, message: erreur instanceof Error ? erreur.message : String(erreur) });
  }
}

const racine = document.querySelector<HTMLElement>("#app")!;
monterGabarit(racine, store, gabarit);

racine.addEventListener("input", evenement => {
  const champ = evenement.target as HTMLInputElement;
  store.ecrire({ ...store.lire(), email: champ.value });
});

racine.addEventListener("click", evenement => {
  const bouton = (evenement.target as Element).closest<HTMLButtonElement>("button[data-atelier]");
  if (bouton) reserver(Number(bouton.dataset.atelier));
});

Le bouton reste désactivé tant que le courriel est vide ou que l’atelier est complet. Le gabarit a donc besoin du texte saisi pour calculer son état, ce qui explique la présence d’email dans le store. Chaque frappe enregistre la valeur du champ et déclenche un nouveau rendu. Un courriel non vide n’est pas forcément valide. La vérification de sa forme reste dans les règles de réservation.

Avec Readonly, TypeScript interdit de réaffecter les propriétés de l’état. Le code construit donc un nouvel objet avec { ...etat } et le transmet au store. Cette vérification reste superficielle, car le tableau des ateliers peut encore être modifié.

Dans cet exemple, une action est une fonction qui traite une demande et reporte son résultat dans l’état de l’interface. La fonction reserver demande d’abord à la classe de créer la réservation. Si elle réussit, l’action relit les places disponibles, prépare le message de confirmation et écrit l’ensemble en une fois dans le store. Si la classe refuse la demande, l’action conserve les ateliers et le courriel, puis remplace seulement le message. Le rendu reçoit ainsi un état qui correspond au résultat de la demande.

flowchart LR accTitle: Le cycle du store accDescr: Un clic appelle l'action reserver. L'action appelle les règles d'Inscriptions, puis écrit le nouvel état dans le store. Le store prévient monterGabarit, qui appelle le gabarit et remplace le HTML de la racine. Clic[Clic] --> Action[reserver] Action --> Regles[Inscriptions] Action -->|ecrire| Store[Store] Store -->|notifie| Montage[monterGabarit] Montage -->|appelle| Gabarit[gabarit] Montage -->|innerHTML| Racine[Racine]

Relancez la page. Collez une adresse complète dans le champ, puis réservez l’atelier Dessin. Le nombre de places passe de 2 à 1. La saisie lettre par lettre révèle le premier des trois problèmes du remplacement du HTML.

Premier défaut, le champ perd le focus

Cliquez dans le champ, puis tapez une lettre. Le curseur disparaît du champ après chaque frappe, et il faut cliquer de nouveau pour taper la suivante.

La frappe déclenche l’événement input, qui écrit le courriel dans le store. Le store appelle alors afficher, qui remplace tout le HTML de la racine. Le champ dans lequel vous tapiez est retiré de la page, et un nouveau champ, créé à partir du gabarit, prend sa place. Le texte saisi réapparaît grâce à l’attribut value, mais le focus, qui désigne l’élément recevant la saisie au clavier, reste associé à l’ancien champ jusqu’à son retrait. Le nouveau champ ne le récupère pas. La position du curseur, une sélection de texte ou le défilement d’une liste peuvent aussi être perdus quand leurs éléments sont remplacés.

Deuxième défaut, les écouteurs disparaissent

Un écouteur posé sur un bouton reste attaché à ce bouton quand le rendu le retire. Lisez ce contre-exemple sans l’ajouter à votre fichier, qui traite déjà les clics sur la racine.

for (const bouton of racine.querySelectorAll<HTMLButtonElement>("button[data-atelier]")) {
  bouton.addEventListener("click", () => reserver(Number(bouton.dataset.atelier)));
}

Ces écouteurs ne concernent que les boutons présents au moment de la boucle. Au premier changement d’état, le rendu retire ces boutons et en crée de nouveaux, sans leur associer les mêmes fonctions. Les clics sur les nouveaux boutons ne déclencheraient donc plus de réservation.

Notre main.ts contourne ce problème par la délégation d’événements. Un clic sur un bouton remonte ensuite vers ses ancêtres, jusqu’à la racine : c’est la propagation des événements. Un seul écouteur posé sur la racine reçoit donc les clics de tous les boutons, y compris ceux créés plus tard. closest retrouve le bouton concerné et data-atelier indique l’atelier. React utilise le même procédé : depuis sa version 17, il pose ses écouteurs sur l’élément racine de l’application.

Troisième défaut, le texte saisi devient du HTML

Les deux premiers défauts gênent la personne, tandis que le troisième ouvre une faille de sécurité. Collez cette adresse d’un seul coup dans le champ, puis cliquez sur Réserver :

<img/src/onerror=alert(1)>@a.fr

Une fenêtre alert s’ouvre. L’adresse passe la validation d’Inscriptions, car l’expression régulière vérifie seulement l’absence d’espace et la présence d’un @ et d’un point. Le message de confirmation contient alors cette adresse, et le gabarit l’insère telle quelle dans le HTML. Le navigateur lit <img/src/onerror=alert(1)> comme une balise d’image sans source, déclenche son erreur et exécute le code de l’attribut onerror.

Cette attaque s’appelle une injection de script, ou XSS pour cross-site scripting. L’OWASP la décrit en détail. Ici, la personne s’attaque elle-même, mais le même gabarit afficherait une adresse enregistrée par quelqu’un d’autre, par exemple dans une liste de participants. Le code injecté s’exécuterait alors dans la page de chaque personne qui la consulte. La documentation d’innerHTML rappelle ce risque.

On pourrait échapper les caractères <, >, & et les guillemets pour qu’ils soient affichés au lieu d’être interprétés. Il faudrait alors adapter cette protection à chaque emplacement, notamment le texte du message et la valeur d’un attribut. Un oubli laisserait de nouveau passer du HTML. La propriété textContent répond à un besoin plus précis. Elle affecte du texte à un élément sans le faire analyser comme du HTML. Le chapitre 3 l’utilise pour afficher le message de confirmation. Cette protection de l’affichage est distincte de la validation du courriel.

Nuance, l’idée reste bonne

Décrire la page à partir de l’état rassemble les mises à jour dans le gabarit. Les trois défauts observés viennent du choix d’innerHTML, qui remplace les éléments existants et interprète la chaîne reçue comme du HTML.

On peut conserver cette description à partir de l’état sans remplacer toute la page. React recalcule une description, la compare avec la précédente, puis détermine les modifications à appliquer aux éléments existants. Le DOM virtuel, présenté au chapitre 7, repose sur cette approche. Les chapitres 3 et 4 explorent une autre solution. On crée les éléments au montage, puis on met à jour leur texte ou leurs propriétés sans les remplacer à chaque changement d’état.

Avant de passer au chapitre 3, gardez creerStore dans framework.ts. Vous pouvez supprimer monterGabarit, qui ne servira plus.

Frameworks 3, composants en fonctions ou en classes

Au chapitre 2, chaque écriture dans le store remplaçait toute la page. Ce remplacement faisait perdre le focus au champ et permettait au texte saisi d’être interprété comme du HTML. On va maintenant conserver les éléments pendant les mises à jour et modifier seulement leur contenu ou leurs propriétés.

Chaque ligne d’atelier deviendra un composant chargé de son affichage. On l’écrira comme une fonction, puis comme une classe dont le navigateur appelle les méthodes à l’insertion et au retrait. On pourra ainsi comparer le nettoyage des abonnements et retrouver l’idée des crochets de Template Method.

Un composant regroupe ce qui change ensemble

Un composant regroupe les éléments d’une partie de l’interface et le code qui les fait réagir. Dans notre page, une ligne d’atelier affiche le titre et les places libres, puis propose un bouton de réservation. Le texte dépend de l’atelier, et le bouton dépend aussi du courriel saisi. Réunir ces éléments dans un composant permet de décrire leur création et leurs mises à jour sans répéter ce code pour chaque atelier.

Ce regroupement convient aux éléments qui se répètent ou changent ensemble, comme le texte et le bouton d’une ligne. Si cette partie de la page disparaît seule, le composant permet aussi de rassembler son nettoyage.

Écrire le composant comme une fonction

Dans src/main.ts, conservez le catalogue, le type Etat, le store et l’action reserver du chapitre 2. Gardez les imports, sauf celui de monterGabarit, à remplacer par monter. Supprimez la fonction gabarit, la constante racine, l’appel à monterGabarit et les deux écouteurs posés sur la racine.

Ajoutez ce composant. La page qui l’utilise sera écrite plus bas. Pour l’instant, le fichier ne contient donc plus de code de montage.

function CarteAtelier(atelierId: number): [HTMLElement, () => void] {
  const carte = document.createElement("span");
  const texte = document.createElement("span");
  const bouton = document.createElement("button");
  bouton.textContent = "Réserver";
  bouton.addEventListener("click", () => reserver(atelierId));
  carte.append(texte, " ", bouton);

  const afficher = (etat: Etat) => {
    const atelier = etat.ateliers.find(a => a.id === atelierId)!;
    texte.textContent = `${atelier.titre}, places libres : ${atelier.places}`;
    bouton.disabled = etat.email === "" || atelier.places === 0;
  };
  afficher(store.lire());
  return [carte, store.abonner(afficher)];
}

À chaque appel, la fonction crée un conteneur, un élément pour le texte et un bouton. Elle définit ensuite afficher, qui retrouve l’atelier dans l’état et modifie seulement textContent et disabled. Les notifications suivantes ne remplacent aucun élément. Le bouton garde donc son écouteur de clic, sans qu’une délégation soit nécessaire.

L’affectation à textContent traite le titre et les places comme du texte. On appliquera la même règle au message de confirmation dans la page. Une adresse contenant <img> y restera du texte, au lieu de devenir une balise.

La fonction renvoie un tableau dont les deux positions ont un sens fixe, appelé un tuple. La première contient l’élément à insérer, la seconde la fonction de désabonnement fournie par le store. Le composant donne ainsi à son appelant les moyens de l’afficher et de cesser ses mises à jour.

La fonction ignore à quel moment son élément sera retiré de la page. Son appelant doit donc conserver le nettoyage et l’exécuter au retrait. Sinon, le store continue d’appeler afficher sur des éléments devenus invisibles. Il manque un code chargé de suivre leur durée de vie.

Le navigateur sait quand un élément est retiré

Le navigateur peut prendre en charge ces moments grâce aux éléments personnalisés, une des technologies regroupées sous le nom Web Components. On écrit une classe qui hérite de HTMLElement, puis on l’enregistre sous un nom contenant un tiret, comme carte-atelier. Le navigateur reconnaît alors certaines méthodes de la classe et les appelle pendant le cycle de vie de l’élément, c’est-à-dire lors de sa connexion au document, de son retrait ou d’autres changements.

  • connectedCallback() s’exécute quand l’élément est connecté au document, directement ou avec l’un de ses ancêtres.
  • disconnectedCallback() s’exécute quand il est déconnecté du document, y compris au retrait de l’un de ses ancêtres.
  • attributeChangedCallback() réagit aux changements des attributs que la classe déclare observer. Notre carte n’en utilise pas.

La documentation MDN rappelle les contraintes du constructeur. Il sert à initialiser l’objet, sans supposer que ses attributs et ses enfants sont disponibles. Il ne doit pas ajouter d’enfants à l’élément. On prépare donc son contenu dans connectedCallback, quand le navigateur signale sa connexion au document. Construire un objet et l’insérer dans la page restent deux opérations distinctes.

Remplacez la fonction CarteAtelier par cette classe.

class CarteAtelier extends HTMLElement {
  #atelierId: number;
  #desabonner = () => {};

  constructor(atelierId: number) {
    super();
    this.#atelierId = atelierId;
  }

  connectedCallback(): void {
    const texte = document.createElement("span");
    const bouton = document.createElement("button");
    bouton.textContent = "Réserver";
    bouton.addEventListener("click", () => reserver(this.#atelierId));
    this.replaceChildren(texte, " ", bouton);

    const afficher = (etat: Etat) => {
      const atelier = etat.ateliers.find(a => a.id === this.#atelierId)!;
      texte.textContent = `${atelier.titre}, places libres : ${atelier.places}`;
      bouton.disabled = etat.email === "" || atelier.places === 0;
    };
    afficher(store.lire());
    this.#desabonner = store.abonner(afficher);
  }

  disconnectedCallback(): void {
    this.#desabonner();
  }
}

customElements.define("carte-atelier", CarteAtelier);

Le corps de connectedCallback reprend la création des éléments, le premier affichage et l’abonnement de la fonction. La classe conserve le désabonnement dans un champ privé, puis l’appelle dans disconnectedCallback. Le code qui crée la carte n’a donc plus à garder ce nettoyage. Il lui suffit de retirer l’élément du document pour que le navigateur déclenche disconnectedCallback.

replaceChildren remplace les enfants de la carte au lieu d’en ajouter après les précédents. Lors d’un déplacement classique qui déconnecte puis reconnecte l’élément, le navigateur appelle disconnectedCallback, puis connectedCallback. La carte se désabonne, reconstruit ses enfants et s’abonne de nouveau. Les enfants restent donc stables pendant les changements du store, pas pendant toute la vie de l’objet.

Le champ #desabonner contient d’abord une fonction vide, remplacée à la connexion. disconnectedCallback peut ainsi l’appeler sans tester si un abonnement existe.

Écrivez maintenant la page. Elle reste une fonction, que monter appelle.

function PageAteliers(): HTMLElement {
  const page = document.createElement("main");
  const champ = document.createElement("input");
  champ.type = "email";
  champ.addEventListener("input", () => store.ecrire({ ...store.lire(), email: champ.value }));
  const etiquette = document.createElement("label");
  etiquette.append("Courriel ", champ);

  const liste = document.createElement("ul");
  for (const atelier of store.lire().ateliers) {
    const ligne = document.createElement("li");
    ligne.append(new CarteAtelier(atelier.id));
    liste.append(ligne);
  }

  const statut = document.createElement("p");
  statut.setAttribute("role", "status");
  store.abonner(etat => {
    statut.textContent = etat.message;
  });

  page.append(etiquette, liste, statut);
  return page;
}

monter(document.querySelector<HTMLElement>("#app")!, PageAteliers);

Le champ est créé au montage et n’est pas remplacé pendant la saisie. Ses événements écrivent toujours le courriel dans le store, mais les abonnés ne reconstruisent plus la page. Chaque CarteAtelier est placée dans un <li>, car les entrées d’une liste <ul> doivent être des <li>. L’appel à new CarteAtelier(atelier.id) est possible parce que customElements.define a enregistré la classe avant sa construction.

La page s’abonne aussi pour écrire le message dans le paragraphe de statut. Elle utilise maintenant du texte, sans interprétation HTML. Elle ne conserve cependant pas la fonction qui permettrait de retirer cet abonnement. La classe règle le nettoyage des cartes, pas encore celui de toute la page.

Vérifier les deux versions

Relancez la page et tapez une adresse complète. Vous pouvez saisir toutes les lettres sans cliquer de nouveau dans le champ, car les notifications ne le remplacent plus. Réservez ensuite l’atelier Dessin. Le message confirme la réservation et les places diminuent.

Collez ensuite l’adresse piégée du chapitre 2, <img/src/onerror=alert(1)>@a.fr, puis réservez. Le message doit afficher la balise comme du texte, sans ouvrir de fenêtre. Le store et la classe de réservation n’ont pas changé. C’est la façon de reporter le message dans la page qui empêche son interprétation comme du HTML.

Pour observer le désabonnement, remplacez temporairement la dernière ligne de main.ts par ces deux lignes. Elles gardent la fonction de démontage et rendent demonter et store accessibles depuis la console du navigateur.

const demonter = monter(document.querySelector<HTMLElement>("#app")!, PageAteliers);
Object.assign(window, { demonter, store });

Dans la console, conservez une référence vers le texte d’une carte avant de démonter la page. Écrivez ensuite un état où chaque atelier a 9 places libres, puis inspectez le texte conservé. Cette écriture sert uniquement à observer les abonnements. Elle modifie la copie destinée à l’affichage, pas les réservations dans la classe.

const texte = document.querySelector("li span");
demonter();
const etat = store.lire();
store.ecrire({ ...etat, ateliers: etat.ateliers.map(a => ({ ...a, places: 9 })) });
texte.textContent;

Avec la classe, le texte conservé garde son ancien nombre de places. Le retrait de la page a déclenché disconnectedCallback, qui a désabonné la carte. L’état du store change toujours, mais cette carte ne reçoit plus ses notifications.

Pour comparer, remplacez la classe par la version fonction et supprimez son enregistrement. Dans la page, écrivez alors const [carte] = CarteAtelier(atelier.id), puis ligne.append(carte). Cette décomposition ignore volontairement la seconde valeur, celle qui permet de se désabonner. Rechargez la page pour repartir d’un nouveau montage et d’un nouveau contexte de console, puis rejouez les commandes. Le texte de la carte retirée affiche cette fois 9 places.

Le store conserve la fonction d’affichage, qui garde elle-même une référence vers les éléments retirés. Ceux-ci ne peuvent donc pas être libérés tant que cet abonnement subsiste. La console retient aussi la référence créée pour l’observation, mais cela ne suffit pas à expliquer la mise à jour. C’est bien l’abonnement oublié qui déclenche encore le rendu.

Template Method, le squelette et les crochets

Le catalogue GoF de 1994 décrit le pattern Template Method. Refactoring.Guru en donne cette définition : il « permet de mettre le squelette d’un algorithme dans la classe mère, mais laisse les sous-classes redéfinir certaines étapes de l’algorithme sans changer sa structure ». La classe mère fixe l’ordre des étapes, et les sous-classes remplissent certaines d’entre elles. Une étape facultative, vide ou dotée d’un comportement par défaut, s’appelle un crochet, ou hook en anglais.

Dans la forme classique du pattern, une méthode de la classe mère contient l’enchaînement des opérations. Elle appelle certaines méthodes que les sous-classes peuvent redéfinir. La sous-classe change ainsi une étape sans réécrire l’ensemble. Le GoF rattache cette organisation au principe d’Hollywood du chapitre 1. Le code qui coordonne le traitement appelle les extensions fournies par l’application.

Notre élément personnalisé illustre une inversion de contrôle comparable, mais ne reproduit pas exactement ce pattern. Le navigateur décide quand appeler les méthodes reconnues de la sous-classe de HTMLElement. CarteAtelier fournit deux de ces crochets, connectedCallback et disconnectedCallback. Elle ne définit pas attributeChangedCallback, car elle ne réagit à aucun attribut.

La différence est la place du code qui coordonne les appels. Il appartient aux algorithmes du navigateur, pas à une méthode de HTMLElement qui enchaînerait des méthodes héritées. Les connexions et les retraits peuvent d’ailleurs se répéter. Le diagramme distingue donc l’héritage de l’élément HTML et les callbacks déclarés par la carte.

classDiagram accTitle: CarteAtelier définit les callbacks appelés par le navigateur accDescr: La classe CarteAtelier hérite de HTMLElement et déclare connectedCallback et disconnectedCallback. Le navigateur appelle ces callbacks à la connexion et au retrait de l'élément. class HTMLElement class CarteAtelier { -atelierId : number -desabonner : function +connectedCallback() void +disconnectedCallback() void } HTMLElement <|-- CarteAtelier

La flèche à pointe creuse se lit « CarteAtelier hérite de HTMLElement », comme dans le chapitre UML. Les callbacks figurent sur la carte, car ils ne sont pas des méthodes définies sur HTMLElement. Lors de customElements.define, le navigateur les recherche sur le prototype de la classe enregistrée.

La fiche aide-mémoire Template Method présente la forme classique du pattern sur l’envoi d’un colis, puis montre où le reconnaître dans les frameworks.

Les classes et les fonctions dans les frameworks réels

On retrouve des cycles de vie dans les frameworks, sans que leurs mécanismes soient identiques. Les composants classes de React héritent de Component. Le framework peut appeler componentDidMount après le montage et componentWillUnmount avant le retrait. Angular utilise des classes dont les méthodes ngOnInit et ngOnDestroy correspondent à l’initialisation et à la destruction du composant. Lit construit ses composants sur les éléments personnalisés et ajoute un cycle de mise à jour à celui du navigateur.

React propose aussi les composants fonctions, avec des hooks depuis 2019. Le mot désigne ici des fonctions de son API, et non des méthodes de sous-classe. Un composant peut notamment déclarer un effet avec useEffect. La fonction passée à cet appel peut renvoyer un nettoyage que React exécutera lorsque l’effet doit être arrêté ou réexécuté. Comme dans le tuple renvoyé par notre CarteAtelier, le nettoyage est une fonction, mais c’est React qui la conserve et l’appelle.

Vue, Svelte et Solid proposent également des composants écrits sous forme de fonctions ou de fichiers compilés, sans imposer l’héritage d’une classe à l’application. Le choix de la syntaxe n’enlève pas le besoin de suivre les abonnements. Au chapitre 5, on construira le mécanisme dont notre version fonction a besoin. Le composant enregistrera son nettoyage auprès du framework au lieu de compter sur son appelant pour le conserver.

Nuance, ce que coûte chaque version

L’héritage de HTMLElement et l’enregistrement dans customElements permettent à la classe de recevoir les notifications du navigateur. Les champs privés conservent l’identifiant de l’atelier et la fonction de désabonnement entre deux appels. Cette intégration dépend cependant des API du navigateur, absentes de Node.js sans environnement DOM supplémentaire.

Notre constructeur reçoit son argument parce que l’application appelle elle-même new CarteAtelier(1). Si le navigateur crée la balise <carte-atelier> écrite dans le HTML, il appelle le constructeur sans argument. Cette classe, telle qu’elle est écrite, ne suffit donc pas pour cet usage. Il faudrait lui transmettre l’identifiant par un attribut ou une propriété et adapter son initialisation.

La fonction n’a besoin ni d’hériter d’un élément HTML ni de s’enregistrer sous un nom de balise. En revanche, quelqu’un doit conserver et exécuter son nettoyage. La classe délègue ce suivi au navigateur, alors que notre fonction attend encore cette aide du framework.

Les deux versions partagent une autre limite. Chaque carte possède une fonction afficher qui met à jour son texte et son bouton à chaque écriture dans le store. Taper une lettre recalcule donc le texte de toutes les cartes, alors que seul l’état des boutons dépend du courriel. Le chapitre 4 sépare ces mises à jour et construit les abonnements à partir des données que chacune lit.

Frameworks 4, des signaux qui s’abonnent seuls

Au chapitre 3, les composants conservaient leurs éléments au lieu de reconstruire la page. Leurs mises à jour restaient pourtant trop larges. Chaque carte s’abonnait au store entier, si bien que taper une lettre recalculait son texte alors que seul son bouton dépend du courriel.

Nous allons remplacer le store par des signaux qui déduisent les abonnements des valeurs lues par le code. Le texte d’une carte ne sera ainsi plus recalculé pendant la saisie du courriel.

Ce que dit la définition

Un signal contient une valeur et permet de suivre les fonctions qui en dépendent. La documentation de Vue décrit, dans sa page sur la réactivité en profondeur, deux opérations complémentaires. Une lecture permet d’enregistrer une dépendance, puis une modification déclenche les effets abonnés.

Un effet est une fonction que le framework exécute en surveillant les signaux qu’elle lit. Elle peut, par exemple, lire le nombre de places et écrire le texte d’une carte. Le framework enregistre alors ce signal comme une dépendance de l’effet, afin de relancer la fonction lorsque la valeur change. Une lecture ordinaire, en dehors d’un effet, renvoie la valeur sans créer d’abonnement.

L’idée est ancienne. En 2010, la bibliothèque Knockout de Steve Sanderson proposait déjà des valeurs observables qui suivaient leurs lecteurs. Solid a ensuite repris et popularisé le mot « signal ». En 2023, l’équipe de Svelte écrit dans l’annonce des runes que la réactivité de Svelte 5 repose sur des signaux, « essentiellement ce que faisait Knockout en 2010 ». Angular, Preact et Vue proposent aujourd’hui leurs propres signaux, et une proposition TC39, au stade 1, étudie leur ajout au langage JavaScript.

En clair

On retrouve le pattern Observer, mais le framework se charge de l’inscription. Avec le store, une vue appelait abonner et précisait la fonction à rappeler. Avec un signal, elle déclare un effet qui lit la valeur dont elle a besoin. Pendant cette exécution, la lecture inscrit l’effet comme abonné. Une écriture qui change la valeur relance ensuite les effets inscrits auprès de ce signal, sans relancer ceux qui n’en dépendent pas.

Prenons le bouton d’une carte. Son état dépend du courriel et des places de l’atelier. L’effet qui calcule disabled s’abonne aux signaux qu’il lit effectivement pendant ce calcul. Il peut ne lire que le courriel si celui-ci est vide, car cela suffit déjà à désactiver le bouton. En revanche, l’effet qui écrit le titre et les places ne lit jamais le courriel. Taper une lettre ne le relance donc pas.

Repérer le besoin

Notre fonction afficher met à jour plusieurs éléments à chaque changement, même ceux qui ne dépendent pas de la donnée modifiée. Séparer ces mises à jour demanderait de gérer à la main leurs abonnements et leurs désabonnements. Un oubli dans la liste des dépendances laisserait alors un élément afficher une valeur périmée.

Construire Signal et effet

Remplacez creerStore et le type Store de src/framework.ts par ce code. Gardez monter au début du fichier.

let effetCourant: Effet | null = null;

export class Signal<T> {
  #valeur: T;
  #lecteurs = new Set<Effet>();

  constructor(valeur: T) {
    this.#valeur = valeur;
  }

  lire(): T {
    effetCourant?.suivre(this.#lecteurs);
    return this.#valeur;
  }

  ecrire(valeur: T): void {
    if (Object.is(this.#valeur, valeur)) return;
    this.#valeur = valeur;
    for (const effet of [...this.#lecteurs]) effet.executer();
  }
}

class Effet {
  #fonction: () => void;
  #sources = new Set<Set<Effet>>();

  constructor(fonction: () => void) {
    this.#fonction = fonction;
  }

  suivre(lecteurs: Set<Effet>): void {
    lecteurs.add(this);
    this.#sources.add(lecteurs);
  }

  executer(): void {
    this.#oublierSources();
    const precedent = effetCourant;
    effetCourant = this;
    try {
      this.#fonction();
    } finally {
      effetCourant = precedent;
    }
  }

  #oublierSources(): void {
    for (const lecteurs of this.#sources) lecteurs.delete(this);
    this.#sources.clear();
  }
}

export function effet(fonction: () => void): void {
  new Effet(fonction).executer();
}

La variable de module effetCourant indique pour quel effet une lecture a lieu. Elle vaut null lorsqu’aucun effet ne s’exécute.

effet(fonction) crée un objet Effet et l’exécute aussitôt. Ce premier passage produit l’affichage initial et découvre les dépendances. executer place l’objet dans effetCourant avant d’appeler sa fonction, puis restaure la valeur précédente dans le bloc finally, même si la fonction lève une erreur. Cette sauvegarde permet aussi les effets imbriqués. Quand l’effet intérieur se termine, effetCourant désigne de nouveau l’effet extérieur, auquel les lectures suivantes seront rattachées.

Pendant cette exécution, chaque appel à lire() trouve l’effet dans effetCourant et appelle sa méthode suivre. Celle-ci conserve le lien dans deux ensembles. Le signal range l’effet parmi ses lecteurs, pour savoir qui prévenir lors d’une écriture. L’effet conserve l’ensemble des lecteurs du signal parmi ses sources, pour pouvoir s’en retirer plus tard. Puisque ces collections sont des ensembles, lire plusieurs fois le même signal ne crée pas plusieurs abonnements.

Une lecture faite hors d’un effet, par exemple dans un écouteur de clic, trouve effetCourant à null et ne crée aucun abonnement. Le suivi se limite donc aux lectures synchrones effectuées pendant l’appel de la fonction. Une lecture différée, après un délai ou une attente asynchrone, n’est plus rattachée à cet effet.

ecrire compare la nouvelle valeur à l’ancienne avec Object.is. Si elles sont identiques, il ne fait rien. Sinon, il remplace la valeur puis relance les lecteurs. Pour un tableau ou un objet, la comparaison porte sur son identité, pas sur son contenu. Modifier un tableau en place puis réécrire ce même tableau ne déclencherait donc pas les effets. L’application écrira de nouveaux tableaux pour signaler les changements.

La copie [...this.#lecteurs] permet de parcourir les abonnés présents au début de l’écriture. Sans elle, la boucle for...of parcourrait directement un ensemble que les effets modifient en s’exécutant. Un effet se retire de ses sources, puis s’y réinscrit quand il relit le signal. La boucle retrouverait alors cet effet à la fin de l’ensemble et le relancerait sans fin.

Avant chaque exécution, #oublierSources retire l’effet de tous les signaux lus au passage précédent. La fonction reconstruit ensuite ses abonnements à partir de ses nouvelles lectures. Les dépendances sont donc dynamiques, car elles peuvent changer avec les conditions du programme. Si une branche ne lit plus un signal, l’effet cesse de s’y abonner au lieu de conserver une dépendance devenue inutile.

La classe Signal protège sa valeur et ses lecteurs dans des champs privés, comme la classe Inscriptions protège ses réservations. Remplacer la valeur impose de passer par ecrire, qui déclenche les lecteurs. Cette protection n’empêche toutefois pas de modifier le contenu d’un objet reçu à la lecture. La classe Effet n’est pas exportée, car l’application doit seulement déclarer des effets avec la fonction effet, sans manipuler leurs abonnements.

flowchart LR accTitle: Les effets lisent les signaux dont ils dépendent accDescr: L'effet du texte lit le signal ateliers. L'effet du bouton lit les signaux email et ateliers. L'effet du message lit le signal message. Écrire dans email ne relance que l'effet du bouton. Ateliers((ateliers)) --> Texte[effet du texte] Ateliers --> Bouton[effet du bouton] Email((email)) --> Bouton Message((message)) --> Statut[effet du message]

Vérifier sans navigateur

Créez tests/signaux.test.ts et exécutez-le avec node tests/signaux.test.ts.

import assert from "node:assert/strict";
import { Signal, effet } from "../src/framework.ts";

const email = new Signal("");
const places = new Signal(2);
const etats: string[] = [];

effet(() => {
  etats.push(email.lire() === "" || places.lire() === 0 ? "désactivé" : "actif");
});
assert.deepEqual(etats, ["désactivé"]);

places.ecrire(1);
assert.deepEqual(etats, ["désactivé"]);

email.ecrire("lea@example.test");
assert.deepEqual(etats, ["désactivé", "actif"]);

email.ecrire("lea@example.test");
assert.equal(etats.length, 2);

places.ecrire(0);
assert.deepEqual(etats, ["désactivé", "actif", "désactivé"]);
console.log("Vérifications réussies.");

Au premier passage, le courriel est vide. La partie gauche de l’opérateur || suffit donc à décider que le bouton est désactivé, sans lire places. L’effet s’abonne seulement au courriel, ce qui explique la deuxième assertion. Le passage de deux places à une ne le relance pas, puisque le résultat resterait « désactivé ».

Quand le courriel est rempli, l’effet se relance et lit aussi places. Il trouve la valeur courante, une place, puis s’abonne à ce signal. Écrire deux fois la même adresse ne provoque pas de nouvelle exécution, grâce à Object.is. En revanche, le passage à zéro place relance l’effet et ajoute « désactivé » au tableau. Ce test montre que les abonnements suivent les lectures réellement exécutées, pas tous les noms de signaux présents dans la fonction.

Réécrire l’interface avec des signaux

Remplacez src/main.ts par cette version.

import { Inscriptions } from "./inscriptions.ts";
import { Signal, effet, monter } from "./framework.ts";

const inscriptions = new Inscriptions([
  { id: 1, titre: "Dessin", capacite: 2, prixCentimes: 2000 },
  { id: 2, titre: "Photo", capacite: 1, prixCentimes: 3000 },
]);

const ateliers = new Signal(inscriptions.listerAteliers());
const email = new Signal("");
const message = new Signal("");

function reserver(atelierId: number): void {
  try {
    const reservation = inscriptions.reserver(atelierId, email.lire());
    ateliers.ecrire(inscriptions.listerAteliers());
    message.ecrire(`Réservation ${reservation.id} confirmée pour ${reservation.email}`);
  } catch (erreur) {
    message.ecrire(erreur instanceof Error ? erreur.message : String(erreur));
  }
}

function LigneAtelier(atelierId: number): HTMLLIElement {
  const ligne = document.createElement("li");
  const texte = document.createElement("span");
  const bouton = document.createElement("button");
  bouton.textContent = "Réserver";
  bouton.addEventListener("click", () => reserver(atelierId));
  ligne.append(texte, " ", bouton);

  const atelier = () => ateliers.lire().find(a => a.id === atelierId)!;
  effet(() => {
    texte.textContent = `${atelier().titre}, places libres : ${atelier().places}`;
  });
  effet(() => {
    bouton.disabled = email.lire() === "" || atelier().places === 0;
  });
  return ligne;
}

function PageAteliers(): HTMLElement {
  const page = document.createElement("main");
  const champ = document.createElement("input");
  champ.type = "email";
  champ.addEventListener("input", () => email.ecrire(champ.value));
  const etiquette = document.createElement("label");
  etiquette.append("Courriel ", champ);

  const liste = document.createElement("ul");
  liste.append(...ateliers.lire().map(atelier => LigneAtelier(atelier.id)));

  const statut = document.createElement("p");
  statut.setAttribute("role", "status");
  effet(() => {
    statut.textContent = message.lire();
  });

  page.append(etiquette, liste, statut);
  return page;
}

monter(document.querySelector<HTMLElement>("#app")!, PageAteliers);

L’état unique du store devient trois signaux, un par donnée qui change indépendamment. Le type Etat et la classe CarteAtelier disparaissent. On revient à une fonction, LigneAtelier, qui crée ses éléments une seule fois et renvoie directement son <li>. Les effets déclarés dans cette fonction prennent en charge leurs mises à jour.

La fonction atelier calcule une valeur dérivée, c’est-à-dire une valeur obtenue à partir d’un autre état. Ici, elle recherche l’atelier de la ligne dans le signal ateliers, sans conserver de résultat en mémoire. Quand un effet l’appelle, l’appel à ateliers.lire() se produit toujours pendant cet effet. Le suivi traverse donc l’appel de fonction, et l’effet s’abonne au signal même s’il ne le lit pas directement dans son propre corps.

Chaque ligne crée deux effets. Le premier écrit le titre et les places, tandis que le second règle l’état du bouton. Taper une lettre ne relance que le second effet de chaque ligne. Quand une réservation réussit, reserver écrit un nouveau tableau dans ateliers. L’effet du texte se relance pour chaque ligne et, tant que le courriel n’est pas vide, celui du bouton aussi.

Le suivi est plus précis qu’avec le store entier, mais il s’arrête au niveau du signal. Toutes les lignes partagent le même tableau d’ateliers. Réserver Dessin relance donc aussi les effets abonnés à ce tableau dans la ligne Photo, même si ses places n’ont pas changé.

reserver lit email.lire() depuis un écouteur de clic. Aucun effet n’est en cours à ce moment, donc cette lecture ne crée pas d’abonnement. La réservation utilise le courriel au moment du clic, mais ne doit pas se rejouer lorsque le courriel change.

Relancez la page et vérifiez le comportement. Les boutons restent désactivés tant que le champ est vide, le focus reste dans le champ pendant la saisie, et les places diminuent après chaque réservation. Le bouton de Photo se désactive après sa réservation, puisque l’atelier n’a qu’une place.

Une fuite dans les effets imbriqués

Le framework sait retirer les dépendances devenues inutiles, mais il ne sait pas encore arrêter un effet dont l’affichage a disparu. Tant que les ateliers restent les mêmes et que la page reste montée, ce manque passe inaperçu. Il devient visible lorsqu’un effet en crée d’autres à chaque reconstruction d’une liste. Créez tests/fuite.ts pour observer leur accumulation.

import { Signal, effet } from "../src/framework.ts";

const email = new Signal("");
const reservations = new Signal([1, 2]);
let executions = 0;

effet(() => {
  for (const _ of reservations.lire()) {
    effet(() => {
      email.lire();
      executions++;
    });
  }
});

reservations.ecrire([1, 2, 3]);
executions = 0;
email.ecrire("sam@example.test");
console.log(`Effets relancés : ${executions}`);

L’effet extérieur représente une liste de réservations, et chaque effet intérieur représente une ligne qui dépend du courriel. Le premier passage crée deux effets de ligne. L’écriture d’un tableau de trois réservations relance l’effet extérieur, qui en crée trois autres sans arrêter les deux premiers.

Le compteur est ensuite remis à zéro pour mesurer seulement les exécutions provoquées par le changement de courriel. Le programme en affiche 5, alors que la nouvelle liste ne demande que trois effets. Les deux anciens sont toujours abonnés au courriel, à côté des trois nouveaux.

Dans une interface, un effet de ligne conserve les éléments qu’il doit mettre à jour. Le signal retient cet effet parmi ses lecteurs, même lorsque les éléments ont quitté la page. Il continue donc de les mettre à jour et empêche le navigateur de libérer leur mémoire. Le chapitre 5 ajoute un lien entre la durée de vie d’un effet et celle du code qui l’a créé, afin d’arrêter ces anciens effets.

Les signaux dans les frameworks réels

Solid sépare les opérations en deux API. La fonction createSignal renvoie une fonction de lecture et une fonction d’écriture, tandis que createEffect déclare l’effet. Vue nomme ref ses conteneurs de valeurs réactives et propose watchEffect pour les effets. Le pseudo-code de sa documentation utilise une variable activeEffect, qui joue le rôle de notre effetCourant. Les fonctions track et trigger correspondent respectivement à suivre et à la boucle d’ecrire.

Angular propose signal, computed et effect pour distinguer l’état modifiable, les valeurs calculées et les effets. Svelte 5 expose les runes $state et $effect plutôt que des appels explicites de lecture et d’écriture. Son compilateur ajoute les opérations nécessaires au suivi. On retrouve donc le même besoin, relier un calcul aux valeurs qu’il lit, sans retrouver nécessairement les mêmes appels dans le code de l’application.

Ryan Carniato, l’auteur de Solid, construit une bibliothèque semblable dans l’article Building a Reactive Library from Scratch, publié en 2021. Son code copie aussi les abonnés avant de les relancer et nettoie les dépendances avant chaque exécution.

Nuance, ce que notre version ne fait pas

Nos effets se relancent immédiatement, pendant chaque écriture. Quand reserver écrit dans ateliers puis dans message, un effet qui lit les deux voit d’abord les nouveaux ateliers avec l’ancien message, puis s’exécute de nouveau. La documentation de Svelte décrit un autre choix. Les nouvelles exécutions y sont groupées après la mise à jour du DOM. Le moment d’exécution varie donc selon le framework.

Les frameworks proposent aussi des valeurs calculées, comme computed dans Angular et Vue. Notre fonction atelier refait sa recherche à chaque appel, même si les données n’ont pas changé. Une valeur calculée conserve son résultat et suit les signaux nécessaires au calcul. Si aucun d’eux n’a changé, une nouvelle lecture réutilise ce résultat. Sinon, elle le recalcule à la demande. Ce mécanisme évite les recherches répétées sans maintenir manuellement une seconde copie de l’état.

Le synchronisme impose aussi des limites. Un effet qui modifie un signal qu’il vient de lire peut se rappeler lui-même sans fin. La copie des abonnés protège le parcours de l’ensemble, mais pas contre cette récursion. De même, le bloc finally restaure l’effet courant après une erreur sans la traiter. Si un effet lève une erreur, celle-ci interrompt la boucle d’écriture, et les lecteurs suivants ne sont pas exécutés.

Toute lecture faite pendant un effet crée un abonnement, y compris une lecture involontaire. Le chapitre 5 en montre un exemple, et Solid propose la fonction untrack pour lire une valeur sans s’y abonner.

Frameworks 5, monter et nettoyer

Au chapitre 4, les signaux ont permis de mettre à jour seulement les affichages qui en dépendent. Il leur manque pourtant une information. Quand une liste remplace ses lignes, les effets des anciennes lignes restent abonnés, car le framework ne sait pas qu’ils ne servent plus.

Un écouteur posé sur document, qui survit au composant, pose le même problème. Nous allons rattacher les effets et les nettoyages à une durée de vie pour les arrêter avant une reconstruction ou au démontage de la page.

Ce que dit la définition

Le cycle de vie d’un composant décrit les étapes de son existence. Après sa création, il est monté, c’est-à-dire inséré dans la page, puis son affichage se met à jour lorsque ses données changent. Il est enfin démonté quand l’application le retire. Ce retrait doit aussi arrêter les activités qui n’ont plus de raison de continuer, comme ses abonnements, ses écouteurs extérieurs ou ses minuteries. Une requête en cours peut également devoir être annulée pour ne pas agir sur un composant disparu.

Solid range ces nettoyages dans des portées. Sa documentation de onCleanup explique que cette fonction « enregistre une fonction de nettoyage dans la portée réactive courante ». La portée d’un composant est nettoyée quand le composant est démonté. La portée d’un effet est nettoyée avant chaque nouvelle exécution de cet effet.

En clair

Une portée regroupe les opérations qu’il faudra défaire ensemble. Pendant la construction de la page, le framework désigne une portée courante. Chaque effet créé et chaque nettoyage demandé s’y inscrivent, sans que les fonctions composants aient à transmettre une liste entre elles. Au démontage, il suffit de détruire cette portée pour arrêter les effets et exécuter les nettoyages enregistrés.

Un effet possède aussi sa propre portée, rattachée à celle qui était courante lors de sa création. Quand il se relance, il vide d’abord sa portée pour arrêter les effets créés au passage précédent. Les nouveaux effets s’y inscrivent ensuite. On distingue ainsi deux moments de nettoyage. Une reconstruction arrête les anciens effets enfants, tandis que le démontage arrête tous les effets de la page.

Dans notre framework, appeler une fonction composant ne crée pas de portée supplémentaire. Les composants utilisent celle dans laquelle ils sont appelés. Ce sont le montage de la page, la création explicite d’une portée et la création d’un effet qui organisent les durées de vie. L’arbre des portées suit donc les créations réactives, pas nécessairement chaque élément de l’arbre HTML.

Le repérer dans le code

Un abonnement à un signal global, à document ou à window peut survivre au composant. Il en va de même d’une minuterie lancée avec setInterval ou d’une requête encore en cours. Les effets qui créent des éléments ou d’autres effets à chaque exécution doivent aussi arrêter ceux du passage précédent.

Un écouteur posé sur un bouton du composant n’a généralement pas besoin d’être retiré séparément. Le bouton et son écouteur peuvent être libérés ensemble, dès que plus rien ne référence le bouton. Un écouteur posé sur document est différent, car document vit aussi longtemps que la page. Il conserve la fonction d’écoute et, par elle, les éléments auxquels cette fonction accède.

Une liste de réservations qui fuit

Pour observer les deux problèmes, on ajoute une liste de réservations et un raccourci clavier. Sous les ateliers, chaque réservation aura un bouton d’annulation. La liste masquera les réservations dont le courriel diffère de celui du champ, sans les supprimer de l’état. La touche / placera le curseur dans ce champ.

Ces deux composants construisent la liste. Ils seront assemblés après la correction du framework, avec l’état et l’action d’annulation dont ils ont besoin.

function LigneReservation(reservation: Reservation): HTMLLIElement {
  const ligne = document.createElement("li");
  const bouton = document.createElement("button");
  bouton.textContent = "Annuler";
  bouton.addEventListener("click", () => annuler(reservation.id));
  ligne.append(`${titres.get(reservation.atelierId)}, ${reservation.email} `, bouton);

  effet(() => {
    ligne.hidden = reservation.email !== email.lire().trim().toLowerCase();
  });
  return ligne;
}

function ListeReservations(): HTMLElement {
  const section = document.createElement("section");
  const titre = document.createElement("h2");
  titre.textContent = "Vos réservations";
  const liste = document.createElement("ul");
  effet(() => {
    liste.replaceChildren(...reservations.lire().map(r => LigneReservation(r)));
  });
  section.append(titre, liste);
  return section;
}

ListeReservations reconstruit toutes ses lignes chaque fois que le signal reservations change. Chaque ligne crée un effet qui la masque si son adresse diffère du courriel saisi. Avec le framework du chapitre 4, remplacer les éléments ne retire pas les effets des anciennes lignes. Ils restent abonnés au courriel et conservent les lignes qu’ils modifient. Après plusieurs réservations et annulations, une seule frappe peut donc relancer de nombreux effets pour des lignes qui ne sont plus affichées. Le script tests/fuite.ts du chapitre 4 reproduit cette accumulation.

Le raccourci clavier pose le second problème. Son écouteur sera inscrit sur document, qui survit à la page des ateliers. Si l’application retire cette page pour en afficher une autre, l’écouteur restera actif. Il conservera l’ancien champ et tentera encore de lui donner le focus à chaque appui sur la touche.

Construire les portées

Réorganisez src/framework.ts selon cette version, qui modifie Effet et monter et ajoute les portées. À l’emplacement indiqué, conservez la classe Signal du chapitre 4. Le commentaire abrège son corps, il ne le remplace pas.

type Portee = { parent: Portee | null; nettoyages: (() => void)[] };

let porteeCourante: Portee | null = null;
let effetCourant: Effet | null = null;

function dans<T>(portee: Portee, fonction: () => T): T {
  const precedente = porteeCourante;
  porteeCourante = portee;
  try {
    return fonction();
  } finally {
    porteeCourante = precedente;
  }
}

function detruire(portee: Portee): void {
  for (const nettoyage of portee.nettoyages.splice(0).reverse()) nettoyage();
}

export function creerPortee<T>(fonction: () => T): [T, () => void] {
  const portee: Portee = { parent: porteeCourante, nettoyages: [] };
  const detruirePortee = () => detruire(portee);
  porteeCourante?.nettoyages.push(detruirePortee);
  return [dans(portee, fonction), detruirePortee];
}

export function auNettoyage(nettoyage: () => void): void {
  if (!porteeCourante) throw new Error("auNettoyage appelé hors d'une portée");
  porteeCourante.nettoyages.push(nettoyage);
}

export type Composant = () => HTMLElement;

export function monter(racine: HTMLElement, composant: Composant): () => void {
  const [element, detruirePortee] = creerPortee(composant);
  racine.append(element);
  return () => {
    detruirePortee();
    element.remove();
  };
}

export class Signal<T> {
  // Le code du chapitre 4 ne change pas.
}

class Effet {
  #fonction: () => void;
  #sources = new Set<Set<Effet>>();
  #portee: Portee;
  #actif = true;

  constructor(fonction: () => void) {
    this.#fonction = fonction;
    this.#portee = { parent: porteeCourante, nettoyages: [] };
    porteeCourante?.nettoyages.push(() => this.#arreter());
  }

  suivre(lecteurs: Set<Effet>): void {
    lecteurs.add(this);
    this.#sources.add(lecteurs);
  }

  executer(): void {
    if (!this.#actif) return;
    this.#nettoyer();
    const precedent = effetCourant;
    effetCourant = this;
    try {
      dans(this.#portee, this.#fonction);
    } finally {
      effetCourant = precedent;
    }
  }

  #arreter(): void {
    this.#actif = false;
    this.#nettoyer();
  }

  #nettoyer(): void {
    for (const lecteurs of this.#sources) lecteurs.delete(this);
    this.#sources.clear();
    detruire(this.#portee);
  }
}

export function effet(fonction: () => void): void {
  new Effet(fonction).executer();
}

Une Portee contient une liste de fonctions de nettoyage et un lien vers sa portée parente. La variable porteeCourante désigne la portée qui reçoit les nouvelles inscriptions, comme effetCourant désigne l’effet qui reçoit les nouvelles dépendances. La fonction dans remplace cette portée le temps d’appeler une fonction, puis restaure la précédente dans son bloc finally.

Ces deux variables ont des rôles distincts. L’effet courant indique à quel calcul rattacher une lecture de signal. La portée courante indique qui devra arrêter un nouvel effet ou appeler un nettoyage. Le lien parent servira au chapitre 6 pour rechercher des valeurs fournies par un ancêtre. Ce n’est pas lui qui déclenche le nettoyage des enfants.

detruire extrait les nettoyages et vide leur liste avec splice(0), puis les appelle dans l’ordre inverse de leur inscription. Les opérations enregistrées en dernier sont ainsi défaites en premier. Comme la liste est déjà vide pendant les appels, une seconde demande de destruction ne rejoue pas les mêmes nettoyages.

creerPortee exécute une fonction dans une nouvelle portée, puis renvoie son résultat avec la fonction qui détruit cette portée. Si une portée parente est courante, elle reçoit cette fonction de destruction dans sa propre liste de nettoyages. Détruire la parente provoquera donc aussi la destruction de l’enfant. La relation de nettoyage vient de cette inscription, et non d’un parcours du lien parent.

monter utilise ce mécanisme pour construire la page dans une portée. La fonction de démontage qu’il renvoie détruit cette portée avant de retirer l’élément. Le framework ne surveille pas lui-même les retraits du DOM. Pour obtenir le nettoyage, le code appelant doit utiliser cette fonction de démontage, plutôt que retirer directement l’élément.

Les composants appellent auNettoyage pour inscrire une fonction dans la portée courante. Si aucune portée n’est active, l’appel lève une erreur au lieu d’ignorer la demande. C’est notamment le cas dans un écouteur de clic, appelé plus tard par le navigateur. La portée reste active pendant la construction synchrone du composant ou l’exécution synchrone d’un effet, pas pendant toute la durée de vie de l’élément.

Dans Effet, le constructeur crée une portée pour les ressources de l’effet, puis inscrit l’arrêt de l’effet auprès de la portée courante. La méthode executer lance ensuite la fonction dans cette portée propre, si bien que les effets enfants et les nettoyages créés pendant l’appel lui appartiennent.

Avant chaque nouvelle exécution, #nettoyer retire les abonnements et vide la portée de l’effet. Dans la liste de réservations, ce nettoyage arrête les effets des anciennes lignes avant de construire les nouvelles. Arrêter l’effet parent produit le même nettoyage et empêche toute exécution ultérieure.

Le champ #actif empêche de relancer un effet déjà arrêté. ecrire copie ses lecteurs avant de les exécuter. Si l’un d’eux en arrête un autre, ce dernier reste dans la copie, mais ignore la demande d’exécution grâce à #actif. Sans ce contrôle, il pourrait se réabonner après son arrêt. Ce cas apparaît quand un parent et ses enfants lisent le même signal. Dans notre liste, leurs dépendances sont distinctes, puisque le parent lit les réservations et les enfants le courriel.

flowchart TB accTitle: Les portées organisent la durée de vie des effets et des nettoyages accDescr: monter crée la portée racine. La page s'y exécute et y inscrit ses effets, le nettoyage du raccourci et la portée de l'effet de liste. À chaque exécution, l'effet de liste vide sa portée, ce qui arrête les effets des anciennes lignes, puis y inscrit les effets des nouvelles lignes. Racine[Portée de monter] --> Statut[Effet du message] Racine --> Raccourci[Nettoyage du raccourci] Racine --> Liste[Effet de la liste] Liste --> L1[Effet de la ligne 1] Liste --> L2[Effet de la ligne 2]

Réécrire l’interface

Dans src/main.ts, reprenez cette organisation en conservant la fonction LigneAtelier du chapitre 4. Ajoutez aussi les fonctions LigneReservation et ListeReservations présentées au début du chapitre, à la place des commentaires qui rappellent leur présence. La version ci-dessous ajoute le signal reservations, l’action annuler et le raccourci clavier.

import { Inscriptions, type Reservation } from "./inscriptions.ts";
import { Signal, auNettoyage, effet, monter } from "./framework.ts";

const inscriptions = new Inscriptions([
  { id: 1, titre: "Dessin", capacite: 2, prixCentimes: 2000 },
  { id: 2, titre: "Photo", capacite: 1, prixCentimes: 3000 },
]);
const titres = new Map(inscriptions.listerAteliers().map(a => [a.id, a.titre]));

const ateliers = new Signal(inscriptions.listerAteliers());
const reservations = new Signal(inscriptions.reservations);
const email = new Signal("");
const message = new Signal("");

function reserver(atelierId: number): void {
  try {
    const reservation = inscriptions.reserver(atelierId, email.lire());
    ateliers.ecrire(inscriptions.listerAteliers());
    reservations.ecrire(inscriptions.reservations);
    message.ecrire(`Réservation ${reservation.id} confirmée pour ${reservation.email}`);
  } catch (erreur) {
    message.ecrire(erreur instanceof Error ? erreur.message : String(erreur));
  }
}

function annuler(reservationId: number): void {
  inscriptions.annuler(reservationId);
  ateliers.ecrire(inscriptions.listerAteliers());
  reservations.ecrire(inscriptions.reservations);
  message.ecrire(`Réservation ${reservationId} annulée`);
}

// LigneAtelier ne change pas depuis le chapitre 4.
// LigneReservation et ListeReservations sont présentés plus haut.

function PageAteliers(): HTMLElement {
  const page = document.createElement("main");
  const champ = document.createElement("input");
  champ.type = "email";
  champ.addEventListener("input", () => email.ecrire(champ.value));
  const etiquette = document.createElement("label");
  etiquette.append("Courriel ", champ);

  const raccourci = (evenement: KeyboardEvent) => {
    if (evenement.key === "/" && document.activeElement !== champ) {
      evenement.preventDefault();
      champ.focus();
    }
  };
  document.addEventListener("keydown", raccourci);
  auNettoyage(() => document.removeEventListener("keydown", raccourci));

  const liste = document.createElement("ul");
  liste.append(...ateliers.lire().map(atelier => LigneAtelier(atelier.id)));

  const statut = document.createElement("p");
  statut.setAttribute("role", "status");
  effet(() => {
    statut.textContent = message.lire();
  });

  page.append(etiquette, liste, statut, ListeReservations());
  return page;
}

monter(document.querySelector<HTMLElement>("#app")!, PageAteliers);

Le raccourci montre comment le composant déclare une ressource à nettoyer. PageAteliers inscrit son écouteur sur document, puis demande son retrait au démontage avec auNettoyage. Elle continue de renvoyer son élément, sans avoir à renvoyer ni à conserver la fonction de nettoyage. L’appel fonctionne parce que monter exécute PageAteliers dans une portée.

annuler n’entoure pas son appel d’un try. La seule erreur possible d’Inscriptions.annuler concerne une réservation inconnue, et le bouton disparaît de la liste dès la première annulation. Si une autre partie de l’application pouvait annuler la même réservation, il faudrait traiter ce refus comme dans reserver.

Vérifier

Exécutez de nouveau node tests/fuite.ts. Le programme affiche maintenant 3 effets relancés au lieu de 5. Avant de créer les trois nouvelles lignes, l’effet extérieur vide sa propre portée et arrête les deux effets précédents. Lui-même n’est rattaché à aucune portée parente dans ce script, il ne sera donc pas arrêté automatiquement. Le test suivant ajoute cette portée parente pour vérifier aussi l’arrêt complet.

Créez ensuite tests/portees.test.ts, qui vérifie le démontage complet et le retrait d’un écouteur. Node.js fournit EventTarget et Event, si bien que le test n’a pas besoin de navigateur.

import assert from "node:assert/strict";
import { Signal, auNettoyage, creerPortee, effet } from "../src/framework.ts";

const email = new Signal("");
const reservations = new Signal([1, 2]);
let executions = 0;

const [, detruire] = creerPortee(() => {
  effet(() => {
    for (const _ of reservations.lire()) {
      effet(() => {
        email.lire();
        executions++;
      });
    }
  });
});

reservations.ecrire([1, 2, 3]);
executions = 0;
email.ecrire("sam@example.test");
assert.equal(executions, 3);

detruire();
executions = 0;
email.ecrire("zoe@example.test");
assert.equal(executions, 0);

const cible = new EventTarget();
let recus = 0;
const [, demonter] = creerPortee(() => {
  const compter = () => recus++;
  cible.addEventListener("ping", compter);
  auNettoyage(() => cible.removeEventListener("ping", compter));
});
cible.dispatchEvent(new Event("ping"));
demonter();
cible.dispatchEvent(new Event("ping"));
assert.equal(recus, 1);

assert.throws(() => auNettoyage(() => {}), /hors d'une portée/);
console.log("Vérifications réussies.");

Le premier test vérifie les deux durées de vie. Après la reconstruction, le courriel ne relance que les trois effets des nouvelles lignes. Après la destruction de la portée parente, il n’en relance plus aucun. Le test de l’écouteur vérifie séparément qu’un événement reçu après le démontage ne provoque plus d’appel. Enfin, la dernière assertion vérifie qu’un nettoyage déclaré sans portée n’est pas accepté.

Relancez aussi node tests/signaux.test.ts, qui doit toujours réussir. Dans le navigateur, appuyez sur / pour placer le curseur dans le champ. Tapez lea@example.test, réservez Dessin, puis tapez sam@example.test et réservez de nouveau Dessin. La liste ne montre que la réservation de l’adresse saisie, car les autres lignes sont masquées. Remettez l’adresse de Léa et cliquez sur Annuler pour faire disparaître sa ligne et rendre une place à Dessin.

Le nettoyage dans les frameworks réels

Chaque framework propose sa façon de déclarer un nettoyage. Dans React, la fonction passée à useEffect peut renvoyer une fonction de nettoyage. React appelle ce nettoyage avant de relancer l’effet avec de nouvelles dépendances, puis après le retrait du composant. En développement, le mode strict ajoute un cycle d’exécution puis de nettoyage avant l’exécution normale. Il permet ainsi de repérer les effets dont le nettoyage ne défait pas correctement les opérations.

Vue propose onUnmounted pour le démontage d’un composant, et ses effets reçoivent une fonction pour inscrire leur propre nettoyage. Dans Svelte 5, un $effect peut renvoyer une fonction de nettoyage, appelée avant la nouvelle exécution et à la destruction de l’effet. Angular fournit DestroyRef, sur lequel on inscrit un nettoyage avec onDestroy. Solid propose onCleanup, dont notre auNettoyage reprend le fonctionnement. Les éléments personnalisés du chapitre 3 utilisent disconnectedCallback.

Ces API confient au framework le moment du nettoyage. Le composant décrit comment retirer un écouteur ou arrêter une activité, sans demander à son appelant de conserver séparément chaque opération à défaire.

Nuance, ce qui reste à surveiller

Le suivi automatique tient compte de l’exécution en cours, pas du nom de la fonction appelée. LigneReservation cherche donc le titre dans une Map construite une fois, titres, au lieu de lire ateliers.lire(). La ligne est construite pendant l’effet de la liste. Si elle lisait le signal ateliers avant de créer son propre effet, cette lecture abonnerait l’effet de la liste aux ateliers.

Une réservation écrit d’abord les ateliers, puis les réservations. Avec cet abonnement involontaire, elle reconstruirait donc la liste deux fois au lieu d’une. La portée de nettoyage ne change pas cette règle de suivi. Quand un composant est appelé dans un effet, ses lectures faites hors de ses propres effets restent attribuées à l’effet parent.

Le nettoyage ne résout pas non plus le coût de la reconstruction. Notre liste recrée toutes ses lignes à chaque changement des réservations. Un champ en cours de saisie perdrait ainsi son contenu et son focus, comme au chapitre 2. Même un bouton peut perdre le focus lorsqu’on le remplace. Les frameworks savent conserver l’identité des lignes, avec une clé comme l’attribut key de React, ou avec un mécanisme de liste comme le composant For de Solid. Ils peuvent ainsi réutiliser la ligne correspondant à une réservation au lieu de reconstruire son élément.

Pour les requêtes et les écouteurs multiples, le navigateur propose AbortController. Un seul controleur.abort(), inscrit avec auNettoyage, peut retirer plusieurs écouteurs déclarés avec l’option signal et annuler une requête fetch en cours. Ce mécanisme serait utile si la page interrogeait le partenaire de la séance Adapter.

La gestion des erreurs reste limitée. Si un nettoyage lève une erreur, detruire interrompt sa boucle. Les nettoyages restants, déjà retirés de la liste, ne seront pas appelés par une seconde destruction. De même, une erreur pendant la construction restaure la portée précédente grâce à finally, mais ne détruit pas automatiquement les ressources déjà créées. Cette version suppose donc que la construction et les nettoyages se terminent sans erreur.

Un effet créé hors de toute portée parente n’a aucun propriétaire chargé de l’arrêter. Ses effets enfants sont nettoyés avant chaque nouvelle exécution, mais lui-même reste abonné. Solid émet un avertissement en développement dans ce cas. Notre framework l’accepte. Créez donc les effets dans un composant monté ou dans une portée créée avec creerPortee.

Le chapitre 6 range les composants dans leur propre fichier. Ils ne pourront plus lire les variables de main.ts, et il faudra leur fournir l’état autrement.

Frameworks 6, fournir les services

Après le chapitre 5, src/main.ts réunit le catalogue, quatre signaux, deux actions et quatre composants. Tant que tout reste dans ce fichier, les composants accèdent aux variables du module, comme email ou reserver. Si on les déplace, cet accès disparaît. Il faut alors décider quel code construit l’état de la page et comment les composants le reçoivent.

Nous allons regrouper l’état et les actions dans une classe, puis transmettre une instance aux composants. Cet état jouera le rôle d’un service, un objet qui donne accès à des données et à des opérations communes. Après les paramètres, on essaiera un contexte qui partage une valeur avec les descendants d’une même portée.

Regrouper l’état et les actions dans une classe

La page mélange encore deux rôles. La présentation crée les éléments, les met à jour et réagit aux actions de la personne. L’orchestration coordonne ces actions avec les règles d’Inscriptions, puis conserve le résultat dans l’état de l’interface. Par exemple, le bouton signale une demande de réservation, tandis que l’orchestration appelle les règles et actualise les places affichées.

Les signaux et les actions reserver et annuler relèvent de ce second rôle. Créez src/etat.ts pour les réunir dans une classe. Les règles de capacité et de doublon restent dans leur classe actuelle, sans dépendre du navigateur.

import type { AtelierAvecPlaces, Inscriptions, Reservation } from "./inscriptions.ts";
import { Signal, type Cle } from "./framework.ts";

export class EtatAteliers {
  readonly ateliers: Signal<AtelierAvecPlaces[]>;
  readonly reservations: Signal<Reservation[]>;
  readonly email = new Signal("");
  readonly message = new Signal("");
  #inscriptions: Inscriptions;

  constructor(inscriptions: Inscriptions) {
    this.#inscriptions = inscriptions;
    this.ateliers = new Signal(inscriptions.listerAteliers());
    this.reservations = new Signal(inscriptions.reservations);
  }

  titre(atelierId: number): string {
    return this.#inscriptions.listerAteliers().find(a => a.id === atelierId)?.titre ?? "";
  }

  reserver(atelierId: number): void {
    try {
      const reservation = this.#inscriptions.reserver(atelierId, this.email.lire());
      this.#actualiser();
      this.message.ecrire(`Réservation ${reservation.id} confirmée pour ${reservation.email}`);
    } catch (erreur) {
      this.message.ecrire(erreur instanceof Error ? erreur.message : String(erreur));
    }
  }

  annuler(reservationId: number): void {
    this.#inscriptions.annuler(reservationId);
    this.#actualiser();
    this.message.ecrire(`Réservation ${reservationId} annulée`);
  }

  #actualiser(): void {
    this.ateliers.ecrire(this.#inscriptions.listerAteliers());
    this.reservations.ecrire(this.#inscriptions.reservations);
  }
}

export const CleEtat: Cle<EtatAteliers> = Symbol("état des ateliers");

La classe reçoit Inscriptions par son constructeur et conserve cette instance dans un champ privé. Un composant qui reçoit cet état ne peut donc pas atteindre les règles par ce champ. Il appelle reserver ou annuler, qui actualisent les signaux après une opération réussie. La méthode privée #actualiser réunit les deux écritures nécessaires pour maintenir les ateliers et les réservations à jour.

Les signaux restent publics. Le mot-clé readonly empêche de remplacer un signal par un autre, mais il ne rend pas son contenu immuable. On peut écrire dans etat.email, pas réaffecter etat.email. Cette classe organise donc les accès sans interdire toute modification extérieure de l’état.

La méthode titre remplace la Map du chapitre 5. Elle lit le catalogue sans passer par un signal. Si une ligne l’appelle pendant l’effet qui reconstruit la liste des réservations, cet appel n’abonne donc pas l’effet au signal des ateliers. On conserve ainsi la séparation entre les données nécessaires à la création d’une ligne et celles dont on veut suivre les changements.

La dernière ligne crée une clé pour demander cet état sans importer une instance particulière. Le type Cle sera ajouté au framework plus bas. D’ici là, TypeScript signale une erreur à cet endroit.

Cette classe ne crée aucun élément HTML. On peut donc vérifier sans navigateur qu’une réservation met à jour les places, qu’un refus produit un message et qu’une annulation libère sa place. Créez tests/etat.test.ts.

import assert from "node:assert/strict";
import { Inscriptions } from "../src/inscriptions.ts";
import { EtatAteliers } from "../src/etat.ts";

const etat = new EtatAteliers(
  new Inscriptions([{ id: 1, titre: "Dessin", capacite: 1, prixCentimes: 2000 }]),
);
etat.email.ecrire(" Lea@example.test ");
etat.reserver(1);
assert.equal(etat.ateliers.lire()[0].places, 0);
assert.equal(etat.message.lire(), "Réservation 1 confirmée pour lea@example.test");

etat.email.ecrire("sam@example.test");
etat.reserver(1);
assert.equal(etat.message.lire(), "Atelier complet");

etat.annuler(1);
assert.equal(etat.ateliers.lire()[0].places, 1);
assert.equal(etat.reservations.lire().length, 0);
console.log("Vérifications réussies.");

Comment un composant obtient-il l’état ?

Déplacez maintenant les quatre composants de main.ts dans un nouveau fichier, src/composants.ts. Ils ne voient plus les variables de main.ts. Trois solutions se présentent.

La première consiste à créer une instance dans un module et à l’importer partout. On écrirait dans etat.ts une ligne comme export const etat = new EtatAteliers(new Inscriptions([...])), puis chaque composant ferait import { etat } from "./etat.ts". Tous ces composants utiliseraient alors la même instance, construite avec le catalogue défini dans le module. Deux pages montées sur le même écran partageraient leurs réservations, même si on voulait les rendre indépendantes.

Exporter une classe ou une fabrique permettrait de créer plusieurs états. Il resterait alors à transmettre à chaque composant l’instance qui lui est destinée.

La deuxième consiste à passer l’état en paramètre de composant en composant :

export function PageAteliers(etat: EtatAteliers): HTMLElement {
  // ...
  page.append(etiquette, liste, statut, ListeReservations(etat));
}

function ListeReservations(etat: EtatAteliers): HTMLElement {
  // ...
  liste.replaceChildren(...etat.reservations.lire().map(r => LigneReservation(etat, r)));
}

function LigneReservation(etat: EtatAteliers, reservation: Reservation): HTMLLIElement {
  // ...
}

Avec les paramètres, les dépendances de chaque composant apparaissent dans sa signature. Un test peut fournir l’état de son choix, et la lecture de l’appel indique d’où il vient. Cette solution convient à notre page, puisque chaque composant utilise l’état.

La transmission devient plus encombrante lorsqu’une valeur traverse plusieurs composants qui n’en ont pas besoin. Une zone intermédiaire doit alors recevoir l’état uniquement pour le transmettre à une liste située plus bas. La documentation de Vue appelle cette situation le props drilling, c’est-à-dire la transmission répétée de propriétés à travers plusieurs niveaux.

La troisième solution associe une valeur à un contexte partagé. Un ancêtre fournit la valeur, puis un descendant l’injecte sans que les composants intermédiaires la transmettent.

Ce que dit la définition

Une dépendance est un objet dont un autre code a besoin pour travailler. EtatAteliers est une dépendance des composants, et la classe qui vérifie les réservations est une dépendance de l’état.

Martin Fowler décrit l’injection de dépendances dans l’article Inversion of Control Containers and the Dependency Injection pattern, publié le 23 janvier 2004. Il emploie ce nom pour distinguer une manière d’assembler les objets de l’inversion de contrôle, notion plus générale.

Fowler compare deux façons d’obtenir une dépendance. Avec un localisateur de services, le code demande l’objet dont il a besoin à un mécanisme de recherche. Avec l’injection, un autre code lui transmet cet objet, par exemple par son constructeur. L’article présente trois formes d’injection, par le constructeur, par un mutateur et par une interface.

En clair, l’injection de dépendances retire au composant la responsabilité de créer ou de choisir ses collaborateurs. Vous l’avez déjà pratiquée dans la séance Strategy : reserver recevait l’objet Tarif de son appelant. EtatAteliers la pratique aussi, puisqu’elle reçoit Inscriptions par son constructeur.

Fournir et injecter

Dans src/framework.ts, ajoutez un champ valeurs au type Portee.

type Portee = {
  parent: Portee | null;
  nettoyages: (() => void)[];
  valeurs: Map<unknown, unknown>;
};

Deux endroits créent une portée, creerPortee et le constructeur d’Effet. Ajoutez valeurs: new Map() à chacun :

const portee: Portee = { parent: porteeCourante, nettoyages: [], valeurs: new Map() };
this.#portee = { parent: porteeCourante, nettoyages: [], valeurs: new Map() };

Ajoutez enfin le type Cle et deux fonctions après auNettoyage.

export interface Cle<T> extends Symbol {
  readonly valeur?: T;
}

export function fournir<T>(cle: Cle<T>, valeur: T): void {
  if (!porteeCourante) throw new Error("fournir appelé hors d'une portée");
  porteeCourante.valeurs.set(cle, valeur);
}

export function injecter<T>(cle: Cle<T>): T {
  for (let portee = porteeCourante; portee; portee = portee.parent) {
    if (portee.valeurs.has(cle)) return portee.valeurs.get(cle) as T;
  }
  throw new Error(`Aucune valeur fournie pour ${cle.toString()}`);
}

fournir range une valeur dans la portée courante, sous une clé. injecter cherche cette clé dans la portée courante, puis remonte vers les parents jusqu’à la racine. La première valeur trouvée est celle que la fonction renvoie. Une portée enfant peut donc masquer la valeur d’un parent sans modifier celle du parent. Si aucune portée ne contient la clé, l’erreur indique quelle dépendance manque.

Le lien parent du chapitre 5 relie les contextes d’exécution. PageAteliers et ListeReservations partagent la portée créée par monter, puisque les fonctions composants ne créent pas chacune une portée. L’effet de la liste crée une portée enfant. Une ligne créée par cet effet y cherche l’état, puis le retrouve dans la portée de monter, où main.ts l’a fourni. La recherche suit donc les portées, plutôt que les éléments HTML.

flowchart BT accTitle: injecter remonte les portées jusqu'à la valeur fournie accDescr: La ligne de réservation appelle injecter dans la portée de l'effet de la liste. La recherche remonte directement à la portée de monter, qui contient la valeur fournie pour CleEtat. ListeReservations ne crée pas de portée propre. Ligne[LigneReservation appelle injecter] --> Effet[Portée de l'effet de la liste] Effet -->|parent| Racine[Portée de monter, CleEtat fournie]

Une clé est un Symbol. Chaque appel à Symbol("état des ateliers") crée une valeur unique, même avec la même description. Deux modules peuvent donc choisir la même description sans désigner la même clé. Une simple chaîne comme "etat" ne possède pas cette propriété. Pour retrouver une valeur, le fournisseur et le composant doivent importer la même clé, et non recréer un symbole portant le même nom.

Le type Cle<T> relie une clé au type de sa valeur, sur le modèle du type InjectionKey de Vue. La propriété valeur n’est pas créée à l’exécution. Sa déclaration transporte seulement le type T, afin que TypeScript puisse le retrouver. Ainsi, injecter(CleEtat) renvoie une EtatAteliers sans conversion dans le composant, et fournir(CleEtat, 42) est refusé à la compilation. Ce contrôle ne valide pas les objets à l’exécution.

Fournir un objet ne décide pas non plus de sa durée de vie. Le code qui assemble l’application choisit s’il construit un état par page ou s’il partage le même état entre plusieurs pages. Le démontage arrête les effets associés à la portée, mais il n’appelle aucune méthode de destruction sur l’objet fourni. Si cet objet possède lui-même des ressources à fermer, il faut prévoir leur nettoyage.

Réécrire les composants

Dans src/composants.ts, chaque composant commence par injecter l’état. Voici la ligne de réservation.

import type { Reservation } from "./inscriptions.ts";
import { auNettoyage, effet, injecter } from "./framework.ts";
import { CleEtat } from "./etat.ts";

function LigneReservation(reservation: Reservation): HTMLLIElement {
  const etat = injecter(CleEtat);
  const ligne = document.createElement("li");
  const bouton = document.createElement("button");
  bouton.textContent = "Annuler";
  bouton.addEventListener("click", () => etat.annuler(reservation.id));
  ligne.append(`${etat.titre(reservation.atelierId)}, ${reservation.email} `, bouton);

  effet(() => {
    ligne.hidden = reservation.email !== etat.email.lire().trim().toLowerCase();
  });
  return ligne;
}

Faites de même dans LigneAtelier, ListeReservations et PageAteliers : ajoutez const etat = injecter(CleEtat); en première ligne, puis remplacez email par etat.email, reserver(atelierId) par etat.reserver(atelierId), et ainsi de suite. Exportez seulement PageAteliers, que main.ts doit monter. Le fichier composants.ts du corrigé montre le résultat complet.

main.ts peut alors se limiter à choisir les objets de l’application et à les assembler.

import { Inscriptions } from "./inscriptions.ts";
import { fournir, monter } from "./framework.ts";
import { CleEtat, EtatAteliers } from "./etat.ts";
import { PageAteliers } from "./composants.ts";

const etat = new EtatAteliers(
  new Inscriptions([
    { id: 1, titre: "Dessin", capacite: 2, prixCentimes: 2000 },
    { id: 2, titre: "Photo", capacite: 1, prixCentimes: 3000 },
  ]),
);

monter(document.querySelector<HTMLElement>("#app")!, () => {
  fournir(CleEtat, etat);
  return PageAteliers();
});

Ce fichier choisit le catalogue, construit Inscriptions puis EtatAteliers, et fournit cette instance à la racine. Les composants demandent l’état sans connaître son catalogue. Un test peut ainsi monter la page avec une autre instance et un autre catalogue. La clé désigne toutefois une classe concrète avec un champ privé, ce qui empêche de lui substituer une fausse classe indépendante sans modifier ce contrat.

Vérifier

Relancez la page : elle se comporte comme au chapitre 5. Relancez aussi node tests/etat.test.ts, node tests/portees.test.ts et node tests/signaux.test.ts.

Ajoutez tests/injection.test.ts pour vérifier la recherche dans les portées.

import assert from "node:assert/strict";
import { creerPortee, fournir, injecter, type Cle } from "../src/framework.ts";

const CleLangue: Cle<string> = Symbol("langue");

creerPortee(() => {
  fournir(CleLangue, "fr");
  creerPortee(() => {
    assert.equal(injecter(CleLangue), "fr");
    fournir(CleLangue, "en");
    assert.equal(injecter(CleLangue), "en");
  });
  assert.equal(injecter(CleLangue), "fr");
});

assert.throws(() => creerPortee(() => injecter(CleLangue)), /Aucune valeur fournie/);
console.log("Vérifications réussies.");

La portée intérieure lit d’abord la langue fournie par sa parente, puis fournit sa propre valeur. Cette valeur masque celle du parent pour les recherches effectuées dans la portée intérieure et ses descendants. Le parent n’a pas changé, si bien qu’une lecture dans la portée extérieure renvoie toujours "fr". La dernière assertion vérifie qu’une dépendance absente produit bien une erreur.

Pour vérifier que le partage dépend du montage, ajoutez un second <div id="app2"> à index.html. Montez-y une autre PageAteliers, avec une autre instance d’EtatAteliers et un autre catalogue. Fournissez chaque instance dans sa propre portée de montage, puis réservez dans la première page. Les places de la seconde doivent rester inchangées. Si les composants importaient tous la même instance depuis un module, ils partageraient au contraire les réservations.

L’injection dans les frameworks réels

Vue propose provide et inject. Un composant parent fournit une valeur, et tout descendant peut l’injecter, quelle que soit sa profondeur. Si plusieurs parents fournissent la même clé, le plus proche gagne. Vue recommande des clés Symbol et propose le type InjectionKey<T>, dont notre Cle<T> reprend le principe.

React propose le contexte, avec createContext et useContext. Svelte offre setContext et getContext, et Solid createContext et useContext. Angular propose un système d’injection de dépendances dans lequel des injecteurs organisés en arbre associent des clés à des fournisseurs. Un composant peut recevoir les services par son constructeur ou les demander avec la fonction inject.

Nuance, injection ou localisateur

Selon la distinction de Fowler, notre injecter ressemble davantage à un localisateur de services. Le composant demande explicitement son service en appelant injecter(CleEtat), au lieu de le recevoir sans rien demander. On retrouve cette demande explicite avec le contexte de React, inject de Vue et la fonction inject d’Angular, sans que leurs systèmes soient identiques. L’injection par le constructeur des classes Angular, ou celle d’Inscriptions dans EtatAteliers, correspond à la définition stricte.

Ce choix rend une dépendance moins visible. La signature LigneReservation(reservation) ne dit plus que la ligne a besoin de l’état. Un test doit donc connaître cette exigence et fournir la clé avant de créer le composant. À défaut, l’erreur apparaît à l’exécution, lors de la recherche de la valeur.

Notre injecter exige aussi qu’une portée soit active. C’est le cas pendant le montage d’un composant, pendant l’exécution d’un effet ou dans une fonction appelée par creerPortee. Un écouteur de clic ordinaire s’exécute plus tard, après la restauration de la portée précédente. Il ne peut donc pas y chercher l’état. Chaque composant obtient la référence pendant sa construction, puis ses écouteurs utilisent la constante conservée.

Vue et React encadrent eux aussi l’endroit où ces fonctions peuvent être appelées, mais leurs règles ne sont pas celles de notre miniature. Dans sa Composition API, Vue demande d’appeler inject pendant la préparation du composant. React impose d’appeler useContext au niveau supérieur d’un composant fonctionnel ou d’un Hook personnalisé, et non dans un écouteur d’événement. Le contexte d’appel fait donc partie du contrat de ces API, autant que la valeur qu’elles renvoient.

Un paramètre rend les besoins du composant visibles et permet de le tester sans contexte de montage. Lorsqu’une dépendance traverse plusieurs composants qui ne l’utilisent pas, le contexte partagé évite cette transmission répétée. En échange, il faut connaître la clé, le fournisseur et le moment où la recherche est autorisée. Fournir tout l’état expose aussi toutes ses opérations publiques, même à un composant qui n’en utilise qu’une partie.

Le framework est complet pour cette série. Le chapitre 7 le compare aux frameworks réels.

Frameworks 7, comparer avec les frameworks réels

Le fichier framework.ts du corrigé rassemble les mécanismes construits depuis le début de la série. Il monte les composants, suit les données lues par les effets, organise les nettoyages et recherche les services fournis. On peut désormais suivre un clic depuis la demande de réservation jusqu’à la mise à jour de la page, sans attribuer ces étapes à un mécanisme invisible.

Cette miniature aide à lire un framework réel, mais elle n’en remplace pas les garanties ni les outils. Ce dernier chapitre compare les mécanismes plutôt que la taille des implémentations. Il distingue notamment le suivi des données, le calcul du rendu et les modifications du DOM, avant de vous proposer la même enquête dans votre projet.

Ce que vous avez construit

Au démarrage, l’application passe une fonction composant à monter. Le framework appelle cette fonction dans une portée et conserve les nettoyages qu’elle déclare. L’application ne décide donc pas seule du contexte d’exécution de son composant. On retrouve l’inversion de contrôle du premier chapitre.

Pendant la construction de la page, chaque effet lit les données dont son affichage dépend. La classe Signal enregistre ces lectures, puis prévient les effets concernés lors d’une écriture. Ce mécanisme reprend Observer, avec un abonnement automatique. Le store précédent utilisait au contraire des abonnements explicites. Dans les deux versions, conserver l’état et prévenir ses lecteurs sont deux responsabilités distinctes. creerStore garde cet état dans une fermeture, tandis que les signaux le conservent dans des champs privés.

Lorsqu’un effet se relance, sa portée arrête les effets enfants et exécute les nettoyages du passage précédent. Au démontage, la portée de montage fait de même pour les ressources qui lui sont rattachées. Les éléments personnalisés du chapitre 3 recevaient leurs appels de cycle de vie du navigateur. Ce fonctionnement illustrait une inversion de contrôle comparable à celle de Template Method, sans reproduire exactement le pattern. Les fonctions composants ont besoin que notre framework organise explicitement ce cycle.

Enfin, fournir et injecter séparent l’assemblage des objets de leur utilisation par les composants. Le contexte permet de retrouver un état sans le transmettre à chaque niveau. À l’intérieur de EtatAteliers, la réception d’Inscriptions par le constructeur reste un exemple d’injection de dépendances au sens strict de Fowler. Le contexte de recherche et le paramètre de constructeur résolvent donc un besoin voisin, mais ne rendent pas les dépendances visibles de la même manière.

Le tableau suivant donne des points d’entrée pour retrouver ces rôles dans quatre frameworks. Il ne décrit pas des API interchangeables. Les règles d’appel, le moment des mises à jour et les garanties de nettoyage doivent être vérifiés dans chaque documentation.

RôleNotre frameworkReactVueSvelte 5Angular
Monter l’applicationmontercreateRoot(...).rendercreateApp(...).mountmountbootstrapApplication
Conserver un étatSignaluseStateref$statesignal
Réagir à un changementeffetnouveau rendu du composantwatchEffect$effecteffect
Déclarer un nettoyageauNettoyagefonction renvoyée par useEffectonUnmountedfonction renvoyée par $effectDestroyRef
Fournir un servicefournir, injectercreateContext, useContextprovide, injectsetContext, getContextinjecteurs, inject
Écrire un composant en classeextends HTMLElementextends Component, API de classespas dans l’API native présentée icipas dans l’API native présentée iciclasse décorée par @Component

La ligne « Réagir à un changement » demande une précaution. Notre effet exécute directement la fonction qui modifie le DOM. Dans React, le nouveau rendu calcule une description de l’interface, et les effets déclarés avec useEffect répondent à un autre besoin, celui de synchroniser le composant avec un système extérieur. Il ne faut donc pas confondre un effet de rendu de notre framework avec une API qui porte le mot « effet ».

Deux questions permettent de comparer les moteurs. Quelles données ont changé, et quelle partie du programme doit recalculer l’affichage ? Puis, une fois ce calcul effectué, quels éléments du DOM faut-il réellement modifier ? Le suivi des dépendances répond à la première question. Le DOM virtuel, présenté ci-dessous, est une façon de répondre à la seconde. Ces mécanismes peuvent être combinés.

Le DOM virtuel de React

Au chapitre 2, on recalculait toute la page à chaque changement. Cette approche rendait le code de rendu lisible, mais l’affectation à innerHTML remplaçait les éléments existants et interprétait le texte comme du HTML. React permet lui aussi de recalculer une description de l’interface, sans remplacer pour autant tous les éléments de la page.

Quand une mise à jour d’état déclenche un rendu, React rappelle la fonction du composant concerné et peut rendre ses descendants. La fonction décrit l’interface attendue au lieu de créer elle-même les éléments du DOM. Cette description forme un arbre d’objets JavaScript, couramment appelé DOM virtuel.

React rapproche ce nouvel arbre du précédent lors de la réconciliation. Il détermine quels éléments peuvent être conservés, puis applique les changements nécessaires au DOM pendant la phase de commit. Un nouveau calcul de rendu n’entraîne donc pas nécessairement une modification du DOM. La documentation de React décrit cette séparation dans Render and Commit.

flowchart LR accTitle: Le cycle du DOM virtuel accDescr: Un changement d'état provoque un nouvel appel du composant, qui renvoie un nouvel arbre virtuel. React compare cet arbre avec le précédent, puis applique seulement les différences au DOM. Etat[Changement d'état] --> Rendu[Appel du composant] Rendu --> Arbre[Nouvel arbre virtuel] Arbre --> Comparaison[Comparaison avec l'arbre précédent] Comparaison --> Commit[Commit des différences dans le DOM]

Cette séparation évite de recréer le champ à chaque frappe. Tant que son type et son identité dans l’arbre restent les mêmes, React conserve l’élément existant, donc son focus. Une modification de cette identité, par exemple par une clé différente, peut au contraire le remplacer. La documentation sur la conservation et la réinitialisation de l’état détaille ces conditions.

Les événements sont déclarés dans la description du composant et React gère leur association aux éléments. On n’a donc pas à réinstaller manuellement les écouteurs après une affectation à une chaîne HTML. Enfin, le texte inséré entre accolades dans JSX est échappé par défaut. L’API dangerouslySetInnerHTML permet au contraire de demander explicitement l’insertion de HTML, avec les précautions que ce choix exige.

Notre framework évite cette comparaison en donnant à chaque effet la fonction qui modifie les éléments concernés. La taille d’une mise à jour dépend toutefois de ce qu’on met dans l’effet. Celui d’une ligne change un texte ou un bouton, tandis que celui des réservations reconstruit toute une liste. Utiliser des signaux ne garantit donc pas à lui seul des mises à jour élément par élément. L’endroit où une valeur est lue détermine aussi l’abonnement, comme l’a montré la nuance du chapitre 5.

Vue illustre la combinaison des approches. Ses ref suivent les dépendances selon le principe des signaux, tandis que son moteur de rendu fondé sur le DOM virtuel compare les descriptions produites par les composants. Angular suit aussi les signaux lus par les gabarits, mais les utilise pour organiser ses mises à jour, sans reproduire notre boucle synchrone. Sa documentation sur les signaux explique ce lien avec le rendu. Le choix ne se réduit donc pas à opposer les signaux au DOM virtuel.

Ce que font les compilateurs

Nos composants créent leurs éléments avec document.createElement. Écrire chaque élément, chaque insertion et chaque mise à jour rend le mécanisme visible, mais occupe beaucoup de place. Un compilateur peut traduire une syntaxe plus concise en JavaScript avant l’exécution. On écrit alors la structure attendue, et l’outil produit une partie du code qui la construit.

JSX, utilisé par React et Solid, ressemble à du HTML placé dans le code, comme <button onClick={reserver}>Réserver</button>. Cette ressemblance ne détermine pas le moteur de rendu. React et Solid acceptent une syntaxe voisine, mais la transforment pour des mécanismes différents. Vue et Svelte proposent aussi des fichiers qui regroupent le gabarit, le script et les styles.

Dans Svelte 5 et Solid, le code compilé s’appuie sur le suivi réactif pour mettre à jour les parties variables du DOM. On peut y reconnaître le travail accompli à la main aux chapitres 4 et 5, sans y retrouver exactement les mêmes fonctions effet ou les mêmes affectations à textContent. Le compilateur et le moteur d’exécution se répartissent ce travail. La compilation réduit ainsi le code à écrire, mais ne supprime pas les questions de dépendances, d’identité des éléments et de nettoyage.

Ce qui manque encore à notre framework

Notre version rend les mécanismes inspectables en laissant de côté plusieurs problèmes rencontrés dans une application réelle. Les frameworks et leurs écosystèmes proposent des réponses à ces problèmes, mais ces réponses diffèrent selon l’outil choisi. Pour comparer une solution, examinez notamment les points suivants :

  • Le regroupement des écritures évite de recalculer plusieurs fois le même affichage pendant une action. Notre version relance les effets immédiatement après chaque écriture, ce qui peut leur faire observer des états intermédiaires.
  • Les valeurs calculées évitent de refaire un calcul dont les données n’ont pas changé. Leur mise en mémoire et le moment du recalcul dépendent du mécanisme proposé.
  • Les listes avec clés permettent de retrouver une ligne existante au lieu de la recréer. Cette identité devient visible lorsqu’une ligne contient un champ en cours de saisie.
  • Le rendu côté serveur produit un premier HTML sans attendre la construction complète de l’interface dans le navigateur. L’hydratation rattache ensuite à ce HTML les comportements de l’application. Le cours Architecture logicielle présente ces stratégies de rendu.
  • Le routage associe les adresses aux pages à afficher. Selon la solution, il appartient au framework lui-même ou à une bibliothèque de son écosystème.
  • Le traitement des erreurs détermine ce qui continue à fonctionner après un échec. Dans notre version, un effet qui lève une erreur interrompt la boucle d’ecrire, et les effets suivants ne sont pas relancés. La séance Observer a posé la même question pour les abonnés.
  • Les outils de développement rendent observables l’arbre des composants, leur état ou leurs mises à jour. Ils aident à expliquer un comportement que la seule lecture du composant ne révèle pas.

Ces limites empêchent de traiter notre miniature comme un outil prêt pour la production. Elles donnent aussi des critères de lecture. Pour chaque mécanisme, on peut désormais demander qui déclenche le travail, quelles données il utilise et quel code en gère la fin.

Essayer dans votre projet

Suivez une réservation du clic jusqu’à l’affichage, comme dans le trajet d’un clic. Essayez ensuite ces situations dans votre projet ou dans notre mini framework :

  • Réservez une place et observez la mise à jour du nombre de places.
  • Réservez dans un atelier complet. Le refus doit s’afficher sans modifier les données.
  • Retirez la vue. Ses abonnements et ses écouteurs extérieurs doivent être nettoyés.
  • Vérifiez qu’une mise à jour de l’affichage ne déclenche pas une nouvelle réservation.

Vérifier sa compréhension

1. Pourquoi ecrire parcourt-il une copie de ses lecteurs ?

Dans la classe Signal, que se passerait-il si la boucle d’ecrire parcourait directement this.#lecteurs au lieu de [...this.#lecteurs] ?

Lire l'explication

Avant chaque exécution, un effet se retire des lecteurs de ses signaux, puis il s’y réinscrit en lisant de nouveau leur valeur. Un Set parcouru par for...of visite les éléments ajoutés pendant le parcours. Sans copie, la boucle retrouverait l’effet qu’elle vient de relancer, le relancerait encore, et ne s’arrêterait jamais. La copie fige la liste des lecteurs au moment de l’écriture.

2. Quels écouteurs faut-il retirer au démontage ?

PageAteliers pose un écouteur click sur chaque bouton Réserver et un écouteur keydown sur document. Lesquels faut-il retirer quand la page est démontée ?

Lire l'explication

Dans cet exemple, il faut retirer explicitement l’écouteur posé sur document. document reste accessible pendant toute la vie de la page. Il conserve donc l’écouteur et les objets que sa fonction référence, dont le champ de saisie, même après le retrait du composant.

Les boutons retirés du DOM peuvent, eux, être libérés avec leurs écouteurs dès que plus rien ne les référence. Leur retrait ne garantit pas à lui seul cette absence de références, mais le code de la page n’en conserve pas ailleurs. C’est pourquoi PageAteliers appelle auNettoyage pour l’écouteur extérieur, sans le faire pour chaque bouton.

3. Pourquoi la classe se désabonne-t-elle sans aide ?

Au chapitre 3, CarteAtelier en classe se désabonne au démontage, alors que la version fonction a besoin que son appelant garde la fonction de désabonnement. D’où vient cette différence ?

Lire l'explication

La différence vient du contrat avec le navigateur, pas d’une capacité automatique des classes à se nettoyer. Pour les éléments personnalisés, le navigateur appelle disconnectedCallback lorsque l’élément quitte le document, y compris après le retrait d’un ancêtre. La classe conserve son désabonnement dans un champ privé et l’exécute dans ce crochet. On retrouve l’inversion de contrôle, puisque le navigateur fixe le moment de l’appel. Le rapprochement avec Template Method ne signifie pas qu’une méthode de HTMLElement organise ces appels.

La fonction du chapitre 3 ne possède pas ce contrat. Elle renvoie donc un nettoyage à son appelant, qui doit le conserver et l’appeler. Au chapitre 5, les portées donnent au framework la responsabilité de mémoriser ces nettoyages. Le composant les déclare, puis le framework les exécute au démontage.

4. injecter est-elle de l’injection de dépendances ?

Selon la distinction de Martin Fowler, notre fonction injecter relève-t-elle de l’injection de dépendances ou du localisateur de services ? Quel code du mini framework correspond à l’autre catégorie ?

Lire l'explication

injecter ressemble à un localisateur de services, puisque le composant demande son état en appelant injecter(CleEtat). Sa signature ne révèle pas cette dépendance. Un test doit donc savoir qu’il faut installer la valeur dans une portée avant d’appeler le composant.

EtatAteliers reçoit au contraire Inscriptions par son constructeur. C’est l’injection au sens de Fowler, car le besoin apparaît dans la signature et un autre code fournit l’objet. Ici, main.ts remplit le rôle d’assemblage. Il choisit les instances, passe l’une au constructeur de l’autre, puis fournit l’état aux composants. Dans les deux mécanismes, le code utilisateur peut recevoir une instance choisie pour un test, sous réserve qu’elle respecte le type attendu.

5. Comment le DOM virtuel et les signaux gardent-ils le focus ?

Au chapitre 2, le champ perdait le focus à chaque frappe, alors que React et notre framework à signaux évitent tous deux ce défaut. Par quels moyens différents y parviennent-ils ?

Lire l'explication

Notre framework crée le champ une seule fois. Les effets mettent à jour le texte des lignes, la visibilité des réservations ou la propriété disabled des boutons, selon les données qui changent. Aucun ne remplace le champ. Sa présence dans le DOM ne dépend donc pas d’une comparaison.

React rappelle la fonction du composant pour recalculer sa description. Si le champ conserve son type et son identité dans l’arbre, la réconciliation réutilise l’élément existant. Dans les deux approches, le focus reste en place parce que le même élément demeure dans la page. Ce résultat n’est pas garanti pour une ligne que notre liste recrée, ni pour un champ auquel React attribue une nouvelle identité.

Pour aller plus loin

Le dossier du corrigé contient l’application complète. Ses fichiers sont framework.ts, etat.ts, composants.ts, main.ts, inscriptions.ts et index.html. Le fichier tests/framework.test.ts regroupe les vérifications des chapitres 4 à 6, et npm test l’exécute.

Pour comparer vos choix avec ceux d’un auteur de framework, lisez l’article de Ryan Carniato Building a Reactive Library from Scratch. La page de Vue sur la réactivité en profondeur explique le même mécanisme avec les Proxy de JavaScript. La proposition TC39 sur les signaux montre ce qu’un standard commun pourrait offrir aux frameworks. Les références du cours rassemblent ces lectures.

SOLID, comprendre les cinq principes

Quand une application évolue, son organisation peut compliquer un changement pourtant limité. Modifier un tarif oblige parfois à relire le code des courriels, tandis qu’un écran de consultation exige inutilement des méthodes de suppression. Une sous-classe peut aussi refuser une opération promise par sa classe parente. Les cinq principes SOLID aident à comprendre ces difficultés et à choisir quelle partie du code modifier.

Ces principes prolongent les bases POO, mais concernent aussi les fonctions et les modules. Ils proposent des questions pour examiner une conception, pas des règles à appliquer à chaque classe. Une séparation se justifie si elle rend un changement plus compréhensible ou moins risqué, compte tenu du code supplémentaire à maintenir.

Ce chapitre fait partie des approfondissements du cours, car les séances de patterns n’en ont pas besoin. Si vous manquez de temps, lisez S et D, puis la section Choisir quel principe examiner. Ces deux principes reviennent dans la plupart des projets web. O, L et I deviennent utiles plus tard, quand une hiérarchie de classes ou une interface trop large rend un changement difficile.

Les exemples reprennent le domaine des réservations d’ateliers. Pour chaque principe, les variantes avant et après traitent le même problème. Elles restent indépendantes des autres exemples, sans construire progressivement une nouvelle application. Les montants sont des nombres entiers de centimes.

Exécutez chaque bloc dans un fichier séparé. Comme dans le premier atelier, un fichier .ts s’exécute avec Node.js 24 ou une version ultérieure. Les assertions comparent les résultats observés aux résultats attendus et lèvent une erreur en cas de différence. Une exécution réussie de ces extraits n’affiche rien.

La vérification des types par TypeScript examine le code avant son exécution. Node.js efface ici les annotations de type sans effectuer ce contrôle. Lorsque le chapitre indique que TypeScript refuse un appel, il parle donc du contrôle des types, pas de l’exécution des assertions.

S, responsabilité unique

L’équipe qui décide des tarifs demande de modifier la réduction solidaire des ateliers. Au même moment, l’équipe qui rédige les confirmations souhaite changer le texte envoyé aux participants. Ces demandes peuvent arriver séparément et pour des raisons différentes. On cherche donc à modifier l’une sans intervenir dans le code de l’autre.

Dans le premier programme, ces deux demandes obligent à modifier la même méthode de la classe de réservation. Le résultat calculé est correct, mais les deux règles sont réunies dans cette méthode. Le problème apparaît donc au moment de faire évoluer le code, pas au moment de l’exécuter.

Avant, le calcul et la rédaction changent ensemble

La classe Reservation conserve l’adresse du participant, le prix et le choix du tarif solidaire. Sa méthode preparerConfirmation calcule le montant à partir de ces champs, puis l’insère dans une phrase. Elle renvoie cette phrase sans envoyer de courriel.

import assert from "node:assert/strict";

class Reservation {
	readonly email: string;
	readonly prixCentimes: number;
	readonly solidaire: boolean;

	constructor(email: string, prixCentimes: number, solidaire: boolean) {
		this.email = email;
		this.prixCentimes = prixCentimes;
		this.solidaire = solidaire;
	}

	preparerConfirmation(): string {
		const montantCentimes = this.solidaire
			? Math.round(this.prixCentimes / 2)
			: this.prixCentimes;
		return `Réservation pour ${this.email} : ${montantCentimes} centimes.`;
	}
}

const lea = new Reservation("lea@example.org", 2001, true);
assert.equal(
	lea.preparerConfirmation(),
	"Réservation pour lea@example.org : 1001 centimes.",
);

Dans l’appel vérifié par l’assertion, le constructeur a rangé 2001 dans this.prixCentimes et true dans this.solidaire. La méthode divise donc 2001 par deux, puis Math.round arrondit 1000.5 à 1001. Le return insère ensuite ce montant et this.email dans le message.

L’assertion confirme le message obtenu pour cette réservation solidaire. Elle ne dit pas si le calcul et la rédaction ont de bonnes raisons de rester ensemble. Changer la formule de réduction ou remplacer la phrase oblige à modifier preparerConfirmation. On intervient donc dans la même méthode pour deux décisions indépendantes, avec le risque de toucher à l’autre en la modifiant.

Après, chaque règle appartient à une classe identifiable

On conserve le même résultat en séparant le calcul du montant et la rédaction du message. Reservation garde ses données et calcule son montant avec montantCentimes(). Une nouvelle classe, Confirmation, demande ce montant, puis l’insère dans le texte.

import assert from "node:assert/strict";

class Reservation {
	readonly email: string;
	readonly prixCentimes: number;
	readonly solidaire: boolean;

	constructor(email: string, prixCentimes: number, solidaire: boolean) {
		this.email = email;
		this.prixCentimes = prixCentimes;
		this.solidaire = solidaire;
	}

	montantCentimes(): number {
		return this.solidaire
			? Math.round(this.prixCentimes / 2)
			: this.prixCentimes;
	}
}

class Confirmation {
	rediger(reservation: Reservation): string {
		const montant = reservation.montantCentimes();
		return `Réservation pour ${reservation.email} : ${montant} centimes.`;
	}
}

const lea = new Reservation("lea@example.org", 2001, true);
assert.equal(lea.montantCentimes(), 1001);
assert.equal(new Reservation("sam@example.org", 2001, false).montantCentimes(), 2001);
assert.equal(
	new Confirmation().rediger(lea),
	"Réservation pour lea@example.org : 1001 centimes.",
);

lea.montantCentimes() applique la règle tarifaire aux champs de lea et renvoie le nombre 1001. Cette méthode ne contient aucune phrase, si bien que Reservation ne sait plus comment la confirmation est rédigée.

L’appel new Confirmation().rediger(lea) reçoit ensuite la réservation entière. La méthode lit son adresse, demande son montant à montantCentimes(), puis renvoie le même message que le premier programme. La rédaction n’a plus à savoir si le tarif était normal ou solidaire, parce que la réservation a déjà tranché cette question.

Les deux premières assertions vérifient désormais le calcul sans passer par le message, pour un tarif solidaire puis un tarif normal. La dernière vérifie le texte produit pour la réservation de Léa. Si le texte change, on modifie la classe Confirmation. Si la réduction change, on examine montantCentimes() et les résultats attendus de ses tests. Le code de rédaction reste inchangé tant qu’il doit afficher le montant calculé.

Cette séparation illustre la responsabilité unique, ou Single Responsibility Principle (SRP). Une responsabilité correspond ici à une raison de changer. Robert C. Martin relie cette raison à un acteur, la personne ou le groupe qui décide d’une règle. On distingue ce rôle de celui du développeur chargé de modifier le fichier. Dans notre exemple, les équipes de tarification et de rédaction prennent deux décisions indépendantes. Reservation porte la première, et Confirmation la seconde.

Le nombre de personnes ne dicte pas le nombre de classes. Une même personne peut décider des tarifs et rédiger les courriels, sans que ces deux activités aient les mêmes raisons d’évoluer. À l’inverse, plusieurs personnes peuvent travailler sur une même règle. Pour choisir une séparation, on regarde quelles décisions changent ensemble et lesquelles doivent pouvoir changer séparément.

Une classe peut donc avoir plusieurs méthodes qui répondent au même besoin. Une responsabilité ne signifie ni une seule méthode par classe, ni un fichier pour chaque ligne. Ici, le découpage ajoute une classe et un appel à suivre. Ce coût se justifie si les deux décisions évoluent séparément, pas parce que toute rédaction exige une classe. Les contrôles des saisies et l’envoi du message restent hors de cet exemple.

Le support ENSICAEN, section 2.1, pages 1 et 2 illustre cette séparation avec un importateur CSV qui distingue la lecture du fichier et l’écriture des données.

L’article en anglais de Robert C. Martin sur la responsabilité unique développe le lien avec les acteurs du changement.

O, ouvert à l’extension, fermé à la modification

Les ateliers proposent déjà un tarif normal et un tarif solidaire. L’association demande maintenant un tarif adhérent qui retire 300 centimes au prix, sans rendre le montant négatif. Où faut-il intervenir pour ajouter cette règle ?

La question concerne la formule tarifaire. Les réservations, les places disponibles et les confirmations ne changent pas dans cet exemple.

Avant, chaque nouveau tarif modifie le calcul commun

import assert from "node:assert/strict";

class Reservation {
	readonly prixCentimes: number;
	readonly tarif: "normal" | "solidaire";

	constructor(prixCentimes: number, tarif: "normal" | "solidaire") {
		this.prixCentimes = prixCentimes;
		this.tarif = tarif;
	}

	montantCentimes(): number {
		if (this.tarif === "solidaire") return Math.round(this.prixCentimes / 2);
		return this.prixCentimes;
	}
}

assert.equal(new Reservation(2001, "normal").montantCentimes(), 2001);
assert.equal(new Reservation(2001, "solidaire").montantCentimes(), 1001);

Le constructeur reçoit ici un nom de tarif et le range dans this.tarif. Avec "solidaire", la condition de montantCentimes() est vraie et la méthode renvoie la moitié du prix arrondie. Avec "normal", elle atteint le dernier return et renvoie le prix entier. Les assertions vérifient ces deux trajets pour 2001 centimes.

Le type du champ autorise seulement "normal" et "solidaire". Pour ajouter le tarif adhérent, il faut modifier ce type, puis ajouter une condition et son calcul. Si les demandes tarifaires se répètent, chaque nouvelle formule oblige à modifier la classe Reservation.

Après, la réservation reçoit la règle qui varie

On peut transmettre la formule elle-même sous la forme d’un objet. L’interface Tarif décrit l’opération attendue, une méthode appliquer qui transforme un prix en montant. Chaque formule devient alors une classe qui implémente cette interface.

Cette version ajoute aussi des contrôles absents du premier extrait. Reservation vérifie le prix dans son constructeur, puis montantCentimes() exécute la formule reçue et vérifie le montant avant de le renvoyer. Ces contrôles sont communs à tous les tarifs, indépendamment du déplacement de la formule dans un objet.

import assert from "node:assert/strict";

interface Tarif {
	appliquer(prixCentimes: number): number;
}

class TarifNormal implements Tarif {
	appliquer(prixCentimes: number): number {
		return prixCentimes;
	}
}

class TarifSolidaire implements Tarif {
	appliquer(prixCentimes: number): number {
		return Math.round(prixCentimes / 2);
	}
}

class TarifAdherent implements Tarif {
	appliquer(prixCentimes: number): number {
		return Math.max(0, prixCentimes - 300);
	}
}

class TarifFixe implements Tarif {
	#montantCentimes: number;

	constructor(montantCentimes: number) {
		this.#montantCentimes = montantCentimes;
	}

	appliquer(): number {
		return this.#montantCentimes;
	}
}

class Reservation {
	#prixCentimes: number;
	#tarif: Tarif;

	constructor(prixCentimes: number, tarif: Tarif) {
		if (!Number.isSafeInteger(prixCentimes) || prixCentimes < 0) {
			throw new Error("Prix invalide.");
		}
		this.#prixCentimes = prixCentimes;
		this.#tarif = tarif;
	}

	montantCentimes(): number {
		const montant = this.#tarif.appliquer(this.#prixCentimes);
		if (!Number.isSafeInteger(montant) || montant < 0) {
			throw new Error("Montant invalide.");
		}
		return montant;
	}
}

assert.equal(new Reservation(2001, new TarifNormal()).montantCentimes(), 2001);
assert.equal(new Reservation(2001, new TarifSolidaire()).montantCentimes(), 1001);
assert.equal(new Reservation(2001, new TarifAdherent()).montantCentimes(), 1701);
assert.equal(new Reservation(200, new TarifAdherent()).montantCentimes(), 0);
assert.equal(new Reservation(2001, new TarifFixe(1500)).montantCentimes(), 1500);
assert.throws(() => new Reservation(-1, new TarifNormal()), /Prix invalide/);
assert.throws(() => new Reservation(2001, new TarifFixe(-1)).montantCentimes(), /Montant invalide/);
assert.throws(() => new Reservation(2001, new TarifFixe(10.5)).montantCentimes(), /Montant invalide/);

Tarif décrit la forme attendue d’une formule. Toute classe qui l’implémente doit fournir appliquer, une méthode appelable avec un nombre de centimes et qui renvoie un nombre. TarifNormal, TarifSolidaire, TarifAdherent et TarifFixe respectent cette signature, chacune avec son propre calcul. Le sens de ces nombres, notamment l’interdiction des montants négatifs, doit être précisé en plus des types.

TarifFixe ignore le prix et renvoie le montant reçu par son constructeur. Sa méthode appliquer() ne déclare donc aucun paramètre, ce que TypeScript accepte. L’appel effectué par la réservation lui passe le prix prévu par l’interface, mais cette formule n’a pas besoin de le lire.

Dans new Reservation(2001, new TarifAdherent()), on crée un objet TarifAdherent et on le transmet au constructeur, qui le range dans le champ privé #tarif. La réservation ne connaît pas la classe exacte de cet objet. Son champ est déclaré avec le type Tarif, donc elle sait seulement qu’il possède une méthode appliquer, et c’est elle qui décide quand l’appeler.

Le contrôle du constructeur accepte 2001, car c’est un entier positif situé dans la plage où JavaScript représente tous les entiers exactement. C’est ce que signifie ici « entier sûr ».

Quand l’assertion appelle montantCentimes(), la ligne this.#tarif.appliquer(this.#prixCentimes) exécute la méthode de l’objet reçu. Pour cette réservation, #tarif désigne un TarifAdherent, si bien que c’est sa version d’appliquer qui s’exécute. Le calcul 2001 - 300 produit 1701, et Math.max(0, 1701) conserve ce montant. Le second contrôle l’accepte, puis le dernier return le renvoie.

La même ligne produit donc un calcul différent selon l’objet transmis. Les bases POO appellent ce mécanisme le polymorphisme.

Avec un prix de 200, le retrait de 300 donnerait -100. Math.max(0, -100) choisit 0 et évite un montant négatif. Les dernières assertions vérifient aussi les refus. Un prix de -1 échoue dès la construction de la réservation, avant tout appel du tarif. Un TarifFixe de -1 ou de 10.5 respecte la signature de Tarif, mais son résultat échoue au contrôle du montant. Une signature TypeScript compatible ne garantit donc pas, à elle seule, un montant acceptable.

Le principe ouvert-fermé, ou Open-Closed Principle (OCP), décrit l’objectif de cette séparation. Ajouter une formule revient à fournir un nouvel objet, sans modifier Reservation, qui l’utilise. On dit que Reservation est ouverte à l’extension par de nouveaux tarifs et fermée aux modifications causées par cet ajout. Elle reste stable parce qu’elle demande toujours la même opération, quelle que soit la formule choisie.

En revanche, le code qui choisit le tarif doit encore évoluer. L’application doit savoir quand fournir un TarifAdherent plutôt qu’un TarifNormal. On ajoute donc du code pour la formule et pour son choix, mais pas de nouvelle branche dans le calcul commun. Le principe ne promet pas qu’une nouvelle demande laissera tout le programme inchangé.

Cette stabilité concerne une variation précise, la formule tarifaire compatible avec le contrat actuel. Une nouvelle règle sur les places disponibles demanderait une autre modification. OCP n’interdit ni les if ni la correction d’un bogue dans le calcul commun. Si deux tarifs restent stables et que la condition initiale se lit bien, ajouter une interface et plusieurs classes peut coûter plus qu’il ne rapporte. Les assertions vérifient les résultats choisis ici, mais elles ne décident pas quelle réduction l’association doit proposer.

Le chapitre Strategy applique cette décision à l’application complète. La partie sur le principe ouvert-fermé de l’article de Philippe Vialatte sur SOLID donne d’autres exemples en C#.

L, substitution de Liskov

Remplacer sans rompre le contrat

Prenons un écran de réservation avec un bouton Annuler. Au clic, l’écran transmet la réservation à une fonction annulerEtConfirmer. Cette fonction demande l’annulation, puis renvoie le message « Annulation confirmée ». Elle doit pouvoir se fier à l’objet qu’elle reçoit.

Le code qui utilise un objet est son appelant. Ici, annulerEtConfirmer est l’appelant de la réservation. Il attend que annuler() rende annulee vrai sans lever d’erreur, y compris si la réservation est déjà annulée. Cette promesse permet d’afficher une confirmation sans ajouter de contrôle après l’appel. Elle est choisie pour cet exemple, pas imposée à toutes les applications de réservation.

Le principe de substitution de Liskov, ou Liskov Substitution Principle (LSP), demande de préserver cette promesse quand on remplace un objet par un autre du même type attendu. Le code qui utilise l’objet doit pouvoir continuer à compter sur les mêmes garanties.

Un remplacement qui échoue

Dans ce premier modèle, Reservation permet l’annulation. On ajoute une réservation ferme, dont les conditions interdisent l’annulation. ReservationFerme hérite de Reservation, mais remplace sa méthode annuler() par une erreur.

Le problème se voit en donnant cette nouvelle réservation à la même fonction annulerEtConfirmer.

import assert from "node:assert/strict";

class Reservation {
	#annulee = false;

	get annulee(): boolean {
		return this.#annulee;
	}

	annuler(): void {
		this.#annulee = true;
	}
}

class ReservationFerme extends Reservation {
	override annuler(): void {
		throw new Error("Cette réservation ne peut pas être annulée.");
	}
}

function annulerEtConfirmer(reservation: Reservation): string {
	reservation.annuler();
	return "Annulation confirmée";
}

const flexible = new Reservation();
assert.equal(annulerEtConfirmer(flexible), "Annulation confirmée");
assert.equal(flexible.annulee, true);

const ferme: Reservation = new ReservationFerme();
assert.throws(() => annulerEtConfirmer(ferme), /ne peut pas être annulée/);
assert.equal(ferme.annulee, false);

Avec flexible, l’état est d’abord confirmé. L’appel reservation.annuler() met ensuite l’état d’annulation à true. L’exécution continue jusqu’au return, qui produit le message attendu. Les deux premières assertions vérifient donc le message et l’état obtenu.

La ligne const ferme: Reservation = new ReservationFerme() présente ensuite la réservation ferme comme une Reservation. TypeScript accepte cette sous-classe, dont la méthode annuler() conserve une signature compatible. Le compilateur ne vérifie pas qu’elle tient la promesse d’annulation. Le type attendu autorise donc l’appel, alors que l’objet reçu en détermine le comportement réel.

Quand annulerEtConfirmer reçoit ferme, la même ligne reservation.annuler() lève cette fois une erreur. Le return n’est jamais atteint et la réservation reste confirmée. Le remplacement a rompu la promesse faite à l’appelant. C’est la violation de Liskov.

assert.throws vérifie que ce contre-exemple produit bien l’erreur annoncée. Cette assertion rend la rupture observable. Elle ne rend pas le modèle correct. Un test demandant une annulation réussie pour toute Reservation échouerait avec ReservationFerme.

Annoncer seulement les capacités possibles

Le refus d’annuler une réservation ferme est une règle métier légitime. Il faut donc changer le contrat annoncé par le type, plutôt que permettre une annulation interdite. Les deux réservations peuvent être consultées, mais seule la réservation flexible peut être annulée.

Le second extrait remplace le précédent. ReservationConsultable permet de lire l’état, et ReservationAnnulable y ajoute l’opération d’annulation. La classe ReservationFerme implémente donc seulement ReservationConsultable, si bien qu’elle expose son état, toujours confirmé, sans hériter d’une méthode impossible à respecter.

import assert from "node:assert/strict";

interface ReservationConsultable {
	readonly annulee: boolean;
}

interface ReservationAnnulable extends ReservationConsultable {
	annuler(): void;
}

class ReservationFlexible implements ReservationAnnulable {
	#annulee = false;

	get annulee(): boolean {
		return this.#annulee;
	}

	annuler(): void {
		this.#annulee = true;
	}
}

class ReservationFerme implements ReservationConsultable {
	get annulee(): boolean {
		return false;
	}
}

function consulter(reservation: ReservationConsultable): string {
	return reservation.annulee ? "Annulée" : "Confirmée";
}

function annulerEtConfirmer(reservation: ReservationAnnulable): string {
	reservation.annuler();
	return "Annulation confirmée";
}

const flexible = new ReservationFlexible();
const ferme = new ReservationFerme();

assert.equal(consulter(flexible), "Confirmée");
assert.equal(consulter(ferme), "Confirmée");
assert.equal(flexible.annulee, false);
assert.equal(ferme.annulee, false);
assert.equal(annulerEtConfirmer(flexible), "Annulation confirmée");
assert.equal(consulter(flexible), "Annulée");
assert.equal(consulter(ferme), "Confirmée");
assert.equal(annulerEtConfirmer(flexible), "Annulation confirmée");
assert.equal(consulter(flexible), "Annulée");

consulter attend une ReservationConsultable. Les deux objets conviennent, et les premières assertions vérifient qu’ils sont confirmés. Dans ces appels, leur lecture laisse bien annulee à false. La fonction de consultation n’a pas besoin de savoir quelle réservation pourrait ensuite être annulée.

annulerEtConfirmer attend désormais une ReservationAnnulable. Elle accepte donc flexible, qui possède cette capacité. Après son appel, consulter(flexible) renvoie « Annulée », tandis que consulter(ferme) renvoie toujours « Confirmée ». La dernière paire d’assertions vérifie qu’une seconde annulation réussit et laisse la réservation annulée.

Écrire annulerEtConfirmer(ferme) provoque maintenant une erreur TypeScript, car la classe ReservationFerme ne possède pas annuler(). On n’a pas besoin d’ajouter un if dans cette fonction pour reconnaître les réservations fermes. Elles ne sont plus présentées comme annulables.

Vérifier les promesses du contrat

La correction rend les capacités explicites, mais la présence d’une méthode ne garantit pas son comportement. Une autre implémentation, c’est-à-dire un autre objet qui fournit les opérations du contrat, pourrait proposer annuler() et ne rien faire. Elle laisserait annulee à false et romprait encore la promesse. Le duck typing et la compatibilité des types ne suffisent donc pas à vérifier Liskov.

Pour chaque implémentation de ReservationAnnulable, les tests doivent vérifier l’annulation et sa répétition. Pour chaque objet consultable, ils doivent aussi vérifier que la lecture ne change pas l’état. Une même série de vérifications peut être appliquée à chaque implémentation, parce que la promesse attendue reste la même. Ces vérifications concernent les interfaces et les objets sans héritage autant que les sous-classes.

Pour décrire ces garanties, on distingue ce qui est exigé avant l’appel, ce qui est garanti après et ce qui doit rester vrai.

  • Une précondition est une exigence avant un appel. Ici, annuler() ne demande pas que la réservation soit encore confirmée. Une nouvelle implémentation qui refuse les réservations déjà annulées ajouterait une exigence et romprait ce contrat.
  • Une postcondition est une garantie après un appel réussi. Ici, annulee vaut true après annuler(). Une méthode qui ne fait rien ne tient pas cette garantie.
  • Un invariant est une règle qui reste vraie dans chaque état valide de l’objet. Ici, l’état d’annulation reste un booléen. Chaque opération doit préserver cette règle. Ce seul invariant ne garantit pas que l’annulation a eu lieu, d’où la postcondition précédente.

Un objet remplaçant doit préserver les invariants, sans exiger davantage avant l’appel ni garantir moins après. On examine aussi les suites d’appels permises par le contrat, comme deux annulations successives suivies d’une consultation. Les assertions en vérifient quelques-unes, sans constituer une preuve pour tous les comportements possibles.

Lever une erreur ne viole pas ce principe si le contrat prévoit ce refus. La violation vient ici d’un refus supplémentaire là où notre contrat garantit l’annulation. On ne décide donc pas si une sous-classe est correcte en regardant seulement si elle lève une erreur, mais en comparant son comportement aux promesses du type attendu.

La séparation des capacités prépare le principe suivant, la ségrégation des interfaces, mais les deux questions restent différentes. Liskov demande si un objet tient les promesses du type attendu. La ségrégation demande si le code dépend seulement des opérations dont il a besoin.

Approfondir

Dans le support ENSICAEN, section 2.3, pages 3 et 4, Square ne respecte pas les promesses des méthodes de Rectangle. La correction utilise la composition. Repérez la promesse perdue lorsque l’on modifie ces objets.

La partie Liskov de Philippe Vialatte présente un autre exemple de substitution.

Repère historique

Le principe porte le nom de Barbara Liskov, chercheuse au MIT. Barbara Liskov et Jeannette Wing développent cette idée de sous-typage comportemental dans A Behavioral Notion of Subtyping, publié en 1994. Le comportement attendu par le code qui utilise un objet est au centre de cette approche.

I, ségrégation des interfaces

Prenons un écran qui consulte une réservation pour afficher l’adresse du participant. Cet écran ne crée ni ne supprime de réservation. Pourtant, la classe qui prépare son affichage exige, dans son constructeur, un objet capable de lire, d’enregistrer et de supprimer.

Cette exigence apparaît lorsqu’on veut consulter une archive qui ne propose que la lecture. Elle contient les données nécessaires à l’écran, mais ne satisfait pas le contrat de son constructeur. Pour réutiliser l’écran, il faut revoir ce qu’il exige de sa source, plutôt qu’inventer des opérations d’écriture sans utilité.

Avant, consulter exige aussi le contrat d’administration

import assert from "node:assert/strict";

type Reservation = {
	id: number;
	atelierId: number;
	email: string;
	montantCentimes: number;
};

interface GestionReservations {
	lire(id: number): Reservation | undefined;
	enregistrer(reservation: Reservation): void;
	supprimer(id: number): void;
}

class ReservationsEnMemoire implements GestionReservations {
	#reservations = new Map<number, Reservation>();

	lire(id: number): Reservation | undefined {
		return this.#reservations.get(id);
	}

	enregistrer(reservation: Reservation): void {
		this.#reservations.set(reservation.id, reservation);
	}

	supprimer(id: number): void {
		this.#reservations.delete(id);
	}
}

class EcranReservation {
	#source: GestionReservations;

	constructor(source: GestionReservations) {
		this.#source = source;
	}

	afficher(id: number): string {
		const reservation = this.#source.lire(id);
		return reservation ? reservation.email : "Réservation introuvable";
	}
}

const source = new ReservationsEnMemoire();
source.enregistrer({
	id: 1, atelierId: 1, email: "lea@example.org", montantCentimes: 2000,
});
const ecran = new EcranReservation(source);

assert.equal(ecran.afficher(1), "lea@example.org");
assert.equal(ecran.afficher(999), "Réservation introuvable");

La classe ReservationsEnMemoire range les réservations dans une Map, une collection qui associe ici un identifiant à une réservation. La préparation de l’exemple crée une instance de cette classe, puis appelle source.enregistrer pour y placer la réservation 1. Cette écriture prépare les données avant de construire l’écran. L’écran reçoit ensuite source dans son constructeur et la garde dans son champ privé #source. Il peut ainsi consulter les données sans les avoir enregistrées lui-même.

Quand on appelle ecran.afficher(1), la ligne this.#source.lire(id) retrouve la réservation dans la Map. La méthode renvoie son adresse, lea@example.org. Avec l’identifiant 999, lire renvoie undefined, car aucune réservation ne correspond. La méthode choisit alors le texte « Réservation introuvable ».

Ces deux trajets utilisent seulement lire. Pourtant, le paramètre du constructeur, source: GestionReservations, exige les trois méthodes de l’interface. TypeScript refuse donc de construire l’écran avec un objet qui propose uniquement lire. Les méthodes enregistrer et supprimer lui manquent, même si l’écran ne les appelle jamais.

Dans ce contexte, le client est le code qui utilise l’objet. Il ne s’agit pas du participant à l’atelier. EcranReservation est le client de source, et son besoin se limite à lire une réservation.

Après, l’écran annonce son besoin de lecture

On peut donc limiter le contrat demandé par le constructeur à ce besoin de lecture. LectureReservations exige seulement une méthode lire. La méthode afficher garde les mêmes instructions, car son travail ne change pas. Pour le vérifier, on fournit cette fois ArchiveReservations, une source dont l’interface publique ne propose ni enregistrement ni suppression. « En lecture seule » décrit ici les opérations offertes par cette source, pas une garantie que ses données ne peuvent jamais changer.

import assert from "node:assert/strict";

type Reservation = {
	id: number;
	atelierId: number;
	email: string;
	montantCentimes: number;
};

interface LectureReservations {
	lire(id: number): Reservation | undefined;
}

class EcranReservation {
	#source: LectureReservations;

	constructor(source: LectureReservations) {
		this.#source = source;
	}

	afficher(id: number): string {
		const reservation = this.#source.lire(id);
		return reservation ? reservation.email : "Réservation introuvable";
	}
}

class ArchiveReservations implements LectureReservations {
	#reservations: Reservation[];

	constructor(reservations: Reservation[]) {
		this.#reservations = reservations;
	}

	lire(id: number): Reservation | undefined {
		const reservation = this.#reservations.find((r) => r.id === id);
		return reservation ? { ...reservation } : undefined;
	}
}

const archive = new ArchiveReservations([
	{ id: 1, atelierId: 1, email: "lea@example.org", montantCentimes: 2000 },
]);
const ecran = new EcranReservation(archive);

assert.equal(ecran.afficher(1), "lea@example.org");
assert.equal(ecran.afficher(999), "Réservation introuvable");
assert.equal(archive.lire(1)?.montantCentimes, 2000);

ArchiveReservations reçoit ses réservations dans son constructeur et ne propose qu’une méthode publique. Lors de lire(1), find parcourt le tableau et retrouve la réservation dont l’identifiant vaut 1. La méthode renvoie alors une copie de cette réservation. afficher reçoit cette copie et renvoie la même adresse que précédemment.

Lors de lire(999), find ne trouve aucune réservation et renvoie undefined. La source renvoie donc undefined, puis l’écran produit le même message d’absence. Les premières assertions vérifient que le changement de contrat n’a pas changé ces résultats. La dernière retrouve le montant 2000 dans la réservation lue. Elle observe une donnée de l’archive, sans prouver que celle-ci est immuable.

L’interface décrit ce que l’écran exige de l’objet reçu. Elle n’impose pas que cet objet possède uniquement ces méthodes. Grâce au typage structurel de TypeScript, une instance de ReservationsEnMemoire, la classe du premier exemple, pourrait aussi convenir sans déclarer implements LectureReservations. Elle possède bien lire, même si elle sait également enregistrer et supprimer.

La ségrégation des interfaces, ou Interface Segregation Principle (ISP), consiste à adapter les contrats aux besoins de leurs clients. Ici, un contrat de consultation évite à l’écran d’exiger les opérations d’administration. On peut donc réutiliser cet écran avec une archive comme avec une source modifiable, sans imposer à l’archive des méthodes qu’elle ne peut pas fournir utilement.

Le contrat contient une seule méthode parce que cet écran n’en utilise qu’une. Si plusieurs opérations sont toujours nécessaires ensemble pour un même rôle, une interface peut les regrouper. Le principe ne demande pas une interface par méthode.

Ce contrat minimal ne constitue pas un système d’autorisations. Si l’objet fourni possède aussi des opérations d’écriture, le contrat de lecture ne les supprime pas. Il limite ce que l’écran peut demander dans le code vérifié par TypeScript, pas ce qu’un autre code peut faire du même objet. Les permissions réelles doivent être contrôlées par le système qui exécute ces opérations. Les assertions de cet exemple vérifient l’affichage, sans prouver une politique de sécurité.

La question diffère aussi de celle de Liskov. Ici, on cherche quelles opérations le client a besoin d’exiger. Liskov demande ensuite si les objets acceptés tiennent les promesses de ces opérations.

Dans le support ENSICAEN, section 2.4, pages 4 et 5, les clients d’un distributeur n’utilisent pas les mêmes opérations de dépôt et de retrait.

L’article SOLID de DigitalOcean illustre la ségrégation des interfaces avec du code PHP.

D, inversion des dépendances

L’association veut réorganiser les données de son application. Aujourd’hui, les réservations se trouvent dans stockage.tables.reservations. Cette organisation pourrait changer sans que le besoin de consultation change. On veut toujours obtenir le montant d’une réservation à partir de son identifiant.

Le code métier exprime ici ce besoin lié à l’activité de l’association. Le code de stockage précise comment retrouver les données. Dans le premier programme, ces deux rôles sont mêlés, car la consultation connaît le chemin exact vers les réservations. Réorganiser le stockage oblige donc à modifier la consultation, même si son travail reste le même.

Avant, le métier connaît l’organisation du stockage

Une Map est une collection qui associe une clé à une valeur. Ici, la clé est un identifiant et la valeur est une réservation. Sa méthode get(id) recherche la réservation associée à cet identifiant.

import assert from "node:assert/strict";

type Reservation = {
	id: number;
	atelierId: number;
	email: string;
	montantCentimes: number;
};

const reservation: Reservation = {
	id: 1, atelierId: 1, email: "lea@example.org", montantCentimes: 2000,
};
const stockage = {
	tables: { reservations: new Map([[reservation.id, reservation]]) },
};

class ConsultationMontant {
	montant(id: number): number {
		const reservation = stockage.tables.reservations.get(id);
		if (!reservation) throw new Error("Réservation introuvable.");
		return reservation.montantCentimes;
	}
}

const consultation = new ConsultationMontant();
assert.equal(consultation.montant(1), 2000);
assert.throws(() => consultation.montant(999), /Réservation introuvable/);

Lors de l’appel consultation.montant(1), la méthode part de l’objet stockage, lit sa propriété tables, puis accède à reservations. Elle appelle ensuite la méthode get(1) de cette Map. La réservation existe, donc le dernier return renvoie son montant, 2000 centimes.

Avec consultation.montant(999), la Map ne trouve aucune réservation et renvoie undefined. Le contrôle if (!reservation) provoque alors l’erreur annoncée par la seconde assertion. La méthode ne doit pas inventer un montant quand la réservation est absente.

La classe ConsultationMontant dépend de l’organisation du stockage. Une dépendance désigne ici une connaissance dont le code a besoin pour fonctionner. Pour écrire cette consultation, on doit connaître tables, reservations et la méthode get. Renommer ou déplacer une table oblige à revenir dans la classe, alors que son besoin n’a pas changé.

Après, le stockage satisfait le contrat demandé par le métier

La consultation a besoin d’une opération plus précise que l’accès à toutes les tables. Elle veut trouver une réservation par son identifiant. On exprime ce besoin avec l’interface LectureReservations. Une classe fournit ensuite cette opération de lecture, et on en transmet une instance à la consultation.

import assert from "node:assert/strict";

type Reservation = {
	id: number;
	atelierId: number;
	email: string;
	montantCentimes: number;
};

interface LectureReservations {
	trouverParId(id: number): Reservation | undefined;
}

class ConsultationMontant {
	#lecture: LectureReservations;

	constructor(lecture: LectureReservations) {
		this.#lecture = lecture;
	}

	montant(id: number): number {
		const reservation = this.#lecture.trouverParId(id);
		if (!reservation) throw new Error("Réservation introuvable.");
		return reservation.montantCentimes;
	}
}

const reservation: Reservation = {
	id: 1, atelierId: 1, email: "lea@example.org", montantCentimes: 2000,
};
const stockage = {
	tables: { reservations: new Map([[reservation.id, reservation]]) },
};

class LectureEnMemoire implements LectureReservations {
	trouverParId(id: number): Reservation | undefined {
		return stockage.tables.reservations.get(id);
	}
}

class StockageEnPanne implements LectureReservations {
	trouverParId(): Reservation | undefined {
		throw new Error("Stockage indisponible.");
	}
}

const consultation = new ConsultationMontant(new LectureEnMemoire());
assert.equal(consultation.montant(1), 2000);
assert.throws(() => consultation.montant(999), /Réservation introuvable/);

const enPanne = new ConsultationMontant(new StockageEnPanne());
assert.throws(() => enPanne.montant(1), /Stockage indisponible/);

ConsultationMontant reçoit maintenant, dans son constructeur, l’objet à qui elle demandera de retrouver les réservations. Elle le garde dans son champ privé #lecture, si bien que l’appel consultation.montant(1) ne transmet plus que l’identifiant recherché. À l’intérieur, this.#lecture.trouverParId(id) remplace tout le chemin vers la Map.

La classe LectureEnMemoire fait le lien avec le stockage actuel. Sa méthode trouverParId reçoit l’identifiant et exécute stockage.tables.reservations.get(id). Pour 1, elle renvoie la réservation, puis montant renvoie 2000. Pour 999, elle renvoie undefined et la consultation conserve son erreur « Réservation introuvable ».

Si on réorganise les tables, on modifie ce chemin dans LectureEnMemoire. La classe ConsultationMontant continue de demander une réservation par identifiant. Elle n’a plus besoin de connaître les noms des tables ou la Map.

Le principe d’inversion des dépendances, ou Dependency Inversion Principle (DIP), décrit cette direction de conception. La règle de haut niveau exprime ce que l’application veut faire, ici consulter le montant d’une réservation. Le détail technique est la manière de fournir les données, ici une Map rangée dans un objet tables. Ces termes distinguent des rôles dans le programme.

Le contrat LectureReservations exprime le besoin de la consultation, plutôt que de reproduire toutes les possibilités du stockage. Dans une application répartie en modules, on le définit du côté du code métier. Le module de stockage dépend alors de ce contrat pour fournir l’opération attendue. Le module métier n’importe pas l’organisation du stockage.

C’est cette relation qui est inversée. Au lieu d’adapter la consultation aux détails du stockage, on adapte le stockage au besoin annoncé par la consultation. Les deux parties dépendent du contrat. Cet exemple reste dans un seul fichier pour suivre les appels. Il illustre la séparation sans réaliser ce découpage en modules.

flowchart LR accTitle: Le métier et le stockage dépendent du contrat de lecture accDescr: ConsultationMontant utilise LectureReservations. LectureEnMemoire satisfait ce contrat. Le code métier ne dépend pas de la Map qui contient les données. Metier["ConsultationMontant"] -->|utilise| Contrat["LectureReservations"] Memoire["LectureEnMemoire"] -.->|satisfait| Contrat

Ces flèches représentent les dépendances du code. Elles indiquent ce que chaque partie doit connaître. Elles ne montrent pas le trajet des appels pendant l’exécution. ConsultationMontant appelle bien l’objet LectureEnMemoire, puis cet objet accède à la Map. Le changement concerne les connaissances nécessaires pour écrire la consultation.

Le contrat distingue aussi une absence et une panne. undefined signifie que la lecture a réussi sans trouver cette réservation. La classe StockageEnPanne, elle, lève une erreur dès qu’on lui demande une lecture. La dernière assertion vérifie que cette erreur traverse montant et arrive jusqu’à son appelant. La même classe ConsultationMontant peut donc être utilisée avec ces deux implémentations, sans changer son code. Cela ne signifie pas qu’elle peut fournir un montant lorsque le stockage est indisponible.

Transformer cette panne en undefined ferait croire qu’aucune réservation ne correspond à l’identifiant. Cela masquerait l’échec de la lecture. Le contrat doit donc préciser les résultats attendus et les erreurs possibles. Le nom d’une interface ne suffit pas à garantir ce comportement.

Le passage de l’objet de lecture au constructeur est une injection de dépendances, plus précisément une injection par le constructeur. On fournit à la classe l’objet dont elle a besoin, plutôt que de lui faire choisir ou construire son stockage avec new. L’injection décrit cette manière de fournir un objet. DIP concerne ce que les différentes parties du programme connaissent et la direction de leurs dépendances.

Passer directement l’objet stockage au constructeur serait aussi une injection. Mais la consultation devrait encore suivre ses tables et leur organisation. On aurait déplacé le choix de l’objet sans supprimer la connaissance du détail technique. Ici, le contrat de lecture permet de séparer ces deux questions.

L’inversion de contrôle désigne l’idée plus générale de confier à un autre code une partie du pilotage des appels. Un framework qui appelle votre gestionnaire lors d’un événement en est un exemple. Dans cet extrait, les appels restent visibles et on crée soi-même new LectureEnMemoire(). Aucun framework ni outil d’injection n’est nécessaire pour effectuer cette séparation.

La Map reste un stockage en mémoire. Elle perd ses données quand le processus s’arrête. Le contrat et les assertions n’apportent aucune garantie de persistance ou de résistance aux accès concurrents. Le chapitre Repository poursuit la discussion sur les contrats de stockage.

Le support ENSICAEN, section 2.5, pages 5 et 6 place des contrats de lecture et d’écriture entre la règle et les outils utilisés.

L’article en anglais de Martin Fowler sur l’injection de dépendances distingue la configuration des objets de leur utilisation. On retrouve cette séparation dans new ConsultationMontant(new LectureEnMemoire()).

Situer SOLID et Clean Code

Robert C. Martin, également connu sous le nom d’Uncle Bob, a contribué à diffuser ces principes de conception. Son article Solid Relevance les présente à travers les problèmes de changement et de dépendances qu’ils cherchent à limiter. Les travaux de Barbara Liskov et de Jeannette Wing sur le sous-typage ont leur propre histoire.

Robert C. Martin est aussi l’auteur de Clean Code: A Handbook of Agile Software Craftsmanship, dont la première édition paraît en 2008. Ce livre traite notamment des noms, des fonctions, des classes et des tests, avec des exemples en Java. Il donne des recommandations de conception à examiner dans leur contexte. SOLID ne résume pas tout le livre, et lire le livre n’est pas un prérequis pour suivre ces exemples.

Choisir quel principe examiner

Commencez par décrire le changement qui pose problème. Si changer un texte menace le calcul du montant, examinez les responsabilités avec S. Si une nouvelle formule tarifaire oblige à modifier un traitement commun déjà stable, examinez la variation avec O.

Si un objet accepté par une fonction refuse une opération promise, écrivez le contrat et examinez L. Si une vue de consultation exige des opérations d’administration, partez du besoin de ce client et examinez I. Si une règle métier connaît les noms de tables ou les méthodes d’un fournisseur, suivez les dépendances et examinez D.

Une même modification peut concerner plusieurs principes. La séparation entre consultation et annulation répond à une promesse impossible dans l’exemple Liskov. Elle donne aussi aux clients des contrats adaptés à leurs besoins.

Exercice, une réservation ferme dans une liste

Une liste doit afficher le statut de toutes les réservations. Une nouvelle réservation ferme peut être consultée mais ne peut jamais être annulée. Une proposition consiste à la faire hériter de ReservationFlexible, puis à redéfinir annuler() pour ne rien faire.

La liste a-t-elle besoin d’une méthode d’annulation pour afficher ces deux types de réservations ? Proposez une interface adaptée à son affichage.

Réponse expliquée

ReservationFlexible promet que l’annulation rend annulee vrai. Une méthode qui ne fait rien conserve la signature mais ne tient pas cette postcondition. La fonction d’annulation pourrait alors annoncer une réussite qui n’a pas eu lieu. Le problème principal relève donc de L, la substitution. Supprimer l’exception du premier contre-exemple ne corrige pas le modèle.

La liste doit recevoir des objets ReservationConsultable, car elle n’a besoin que de lire annulee. Les réservations fermes et flexibles satisfont toutes deux ce contrat. La liste peut donc afficher les deux sans connaître leurs conditions d’annulation. Cette décision suit aussi I, car le client de consultation n’exige plus une opération dont il ne se sert pas.

Les assertions de la solution Liskov vérifient que les deux réservations se consultent à l’état confirmé, puis que seule la flexible change après son annulation. La seconde annulation garde le même état. Le code qui annule reçoit ReservationAnnulable. Passer une réservation ferme à ce code doit être refusé par TypeScript, plutôt que conduire à une annulation silencieusement ignorée.

UML, lire et dessiner un diagramme de classes

Une réservation concerne un atelier, mais un atelier peut recevoir plusieurs réservations. Un diagramme de classes rend cette différence visible, sans parcourir tous les fichiers du programme. On y choisit les éléments à montrer, leurs données, leurs opérations et les relations qui les unissent.

Dans ce chapitre, vous allez lire ces informations, puis dessiner le modèle des ateliers et des réservations. L’objectif est de pouvoir expliquer ce que chaque symbole affirme, mais aussi de reconnaître les règles que le dessin ne suffit pas à exprimer.

Ce chapitre fait partie des approfondissements. Pour lire les schémas des autres chapitres, les sections Lire une classe et Reconnaître les relations suffisent. Dessiner un modèle complet sert surtout à présenter une conception, dans un dossier technique, un entretien ou un jury.

Choisir ce que le diagramme doit montrer

UML signifie Unified Modeling Language. Ce langage propose plusieurs types de diagrammes, selon la question que l’on cherche à résoudre. Le diagramme de classes décrit les types d’objets du modèle et les relations possibles entre leurs instances. Un diagramme de séquence montre plutôt qui appelle qui, dans quel ordre.

Le même langage permet de travailler à des niveaux différents. Pour discuter des règles de réservation, on peut représenter un atelier et une réservation sans décider comment les coder. Pour expliquer un programme existant, on montre plutôt ses classes, leurs membres et leur visibilité. Il faut annoncer si le dessin décrit les concepts de l’activité ou des choix de programmation.

Dans les autres chapitres, certains schémas montrent le parcours d’une demande à travers le programme. La présence de rectangles et de flèches ne suffit pas à en faire des diagrammes de classes UML. Un diagramme de classes décrit les relations entre les éléments du modèle, et la forme de chaque lien a un sens précis.

Nous utilisons Mermaid pour produire les diagrammes à partir de texte. UML fournit la notation du modèle, Mermaid fournit une façon de la dessiner. On peut aussi dessiner les mêmes symboles sur papier.

Lire une classe

Reprenons la classe Creature des fondations pour décrire une classe du programme. Son rectangle comporte trois compartiments qui séparent le nom, en haut, les attributs, au milieu, et les opérations, en bas.

classDiagram accTitle: L'énergie et les actions d'une créature accDescr: La classe Creature possède un attribut privé energie de type number. Elle expose avancer, qui renvoie un booléen, et seReposer, qui ne renvoie pas de valeur. class Creature { -energie : number +avancer() boolean +seReposer() void }

energie est un attribut, c’est-à-dire une donnée portée par les instances de la classe. avancer() et seReposer() sont des opérations, reconnaissables à leurs parenthèses. Le type boolean indique que avancer() renvoie true ou false. void indique l’absence de valeur de retour. Le schéma omet ici le getter et la constante d’énergie maximale pour se concentrer sur ces trois membres.

Cette liste ne décrit pas le déroulement des opérations. Le type numérique de l’énergie n’impose pas non plus une valeur entre 0 et 100. Cette règle doit être précisée dans une explication et vérifiée dans le code.

Les signes placés devant les membres indiquent leur visibilité. Le signe moins réserve l’énergie à la classe, tandis que le signe plus rend les opérations accessibles depuis l’extérieur. Le tableau récapitule ces signes et celui des membres protégés :

Signe UMLVisibilitéLecture
+PubliqueLe code qui utilise l’objet peut accéder à ce membre.
-PrivéeCe membre appartient au fonctionnement interne de la classe.
#ProtégéeLa classe et ses sous-classes peuvent accéder à ce membre.

Le # d’UML signifie « protégé ». Dans le code JavaScript #energie, le même caractère déclare un champ privé. Ce champ se représente donc par -energie sur le diagramme. Les symboles UML ne sont pas la syntaxe du langage de programmation.

Un rectangle Creature décrit une classe. Il ne représente pas une grenouille précise avec 90 points d’énergie. Montrer les valeurs de plusieurs objets à un instant donné relève du diagramme d’objets.

Reconnaître les relations

Une association relie des types dont les objets peuvent être liés, par exemple des commandes et des réductions. Une flèche de navigation précise dans quel sens un objet peut accéder à l’autre. Elle ne montre pas l’ordre des appels pendant l’exécution.

Une dépendance signale plutôt qu’un code utilise un type pour effectuer son travail, par exemple comme paramètre d’une méthode. Elle n’impose pas de conserver un objet de ce type parmi ses attributs.

Dans les exemples suivants, A et B désignent des classes ou des interfaces. Le tableau sert de repère pour retrouver les écritures Mermaid. Les triangles et le losange plein sont ensuite expliqués sur des dessins.

RelationÉcriture MermaidLecture
AssociationA --> BLa flèche indique ici que l’on peut naviguer de A vers B. A -- B laisse ce sens non précisé.
DépendanceA ..> BA utilise B pour une opération, sans nécessairement en garder une référence.
Généralisation, ou héritageA --|> BA spécialise B. Le trait est plein et le triangle creux pointe vers le parent B.
Réalisation d’une interfaceA ..|> BA respecte le contrat B. Le trait est pointillé et le triangle creux pointe vers B.
CompositionA *-- BA possède des parties B, qui ne sont pas partagées entre plusieurs propriétaires à la fois. Le losange plein est du côté du tout A.
Agrégation partagéeA o-- BLe losange creux indique un regroupement dont les règles doivent être précisées dans le modèle. Il n’impose pas la propriété exclusive de la composition.

Une association suffit à représenter beaucoup de collaborations. Le mot « utilise » ne justifie pas à lui seul une relation d’héritage ou un losange. De même, l’absence de flèche ne prouve pas que la navigation est possible dans les deux sens. Elle signifie seulement que le dessin ne précise pas cette information.

Le triangle pointe vers le parent

Imaginons une classe Grenouille qui spécialise Creature. Contrairement à une association, l’héritage affirme que les instances de la classe spécialisée sont aussi des instances du type parent. On peut lire le lien « une grenouille est une créature ».

classDiagram direction LR accTitle: Grenouille est une spécialisation de Creature accDescr: Un trait plein relie Grenouille à Creature. Le triangle creux pointe vers Creature, la classe parente. Creature <|-- Grenouille

Le triangle creux pointe vers la classe générale. On part donc de Grenouille pour lire la relation en allant vers Creature. Le trait plein représente ici l’héritage.

Cette relation engage aussi le comportement de la sous-classe. Si avancer() promet de renvoyer un booléen sans rendre l’énergie négative, la spécialisation doit préserver ces règles. La phrase « est un » aide à formuler la relation, mais le principe de substitution de Liskov permet d’en vérifier le comportement.

Une collaboration peut passer par une interface

Pour calculer son total, une commande peut utiliser une réduction sans en hériter. Dans cet exemple de Strategy, deux classes proposent des façons de calculer cette réduction derrière un même contrat.

classDiagram direction TB accTitle: Une commande utilise un contrat de réduction accDescr: Commande référence l'interface Reduction. SansReduction et Coupon réalisent cette interface et fournissent l'opération calculer. class Commande { +total() number } class Reduction { <<interface>> +calculer(totalCentimes) number } class SansReduction class Coupon Commande --> Reduction : utilise Reduction <|.. SansReduction Reduction <|.. Coupon

Le rectangle de la réduction porte la mention « interface ». Il annonce une opération de calcul, sans fournir ici son code. La flèche simple part de la commande et pointe vers ce contrat. La commande connaît donc les opérations à utiliser, sans avoir besoin de connaître la classe concrète choisie pour la réduction.

Les traits pointillés partent des deux classes concrètes et se terminent par un triangle creux près de l’interface. Ils indiquent que ces classes réalisent le contrat, donc qu’elles fournissent le comportement attendu. Contrairement au trait plein de l’héritage, ils n’annoncent pas une reprise de code. La commande n’hérite pas de la réduction. La séance Strategy applique la même structure aux tarifs, avec l’interface Tarif et les classes TarifNormal et TarifSolidaire.

Le losange plein indique une propriété forte

Une association peut aussi préciser qu’un objet est une partie d’un autre. Prenons un panier et ses lignes pour représenter une relation de composition. Dans ce modèle, chaque ligne appartient à un seul panier et n’a pas d’existence indépendante de celui-ci.

classDiagram direction LR accTitle: Les lignes font partie d'un panier accDescr: Un panier possède zéro ou plusieurs lignes. Chaque ligne appartient à exactement un panier. Le losange plein se trouve du côté de Panier. Panier "1" *-- "0..*" LignePanier : contient

Le losange plein est collé au panier, qui représente le tout, et non à la ligne, qui représente une partie. Il exprime une propriété exclusive, car une même ligne ne fait pas partie de plusieurs paniers à la fois. Si on supprime le panier du modèle, les lignes qui lui appartiennent disparaissent avec lui.

Ce dessin engage les règles du modèle, pas un mécanisme automatique de JavaScript ou de la base de données. Le programme doit appliquer la suppression prévue. Le fait de conserver des lignes dans un tableau ne suffit donc pas à justifier un losange plein.

Dans le code, « préférer la composition à l’héritage » signifie souvent assembler des objets qui collaborent. Cela n’impose pas une composition UML avec un losange plein. Une règle de réduction fournie à une commande peut exister et servir ailleurs. L’association du diagramme précédent décrit ce cas.

Lire les nombres aux extrémités

Une multiplicité précise combien d’objets d’un côté peuvent être liés à un objet donné de l’autre côté. Il s’agit des nombres autorisés par le modèle, pas du nombre de réservations déjà enregistrées.

Les notations usuelles décrivent un nombre exact ou un intervalle :

MultiplicitéSignification
1Exactement un.
0..1Aucun ou un seul.
0..*Aucun, un ou plusieurs.
1..*Au moins un.

Dans notre modèle, un atelier peut exister avant toute réservation. Chaque réservation concerne exactement un atelier.

classDiagram direction LR accTitle: Un atelier peut recevoir plusieurs réservations accDescr: Pour un atelier donné, il existe zéro ou plusieurs réservations. Pour une réservation donnée, il existe exactement un atelier. Atelier "1" -- "0..*" Reservation : concerne

Pour lire le diagramme, partez d’un objet et regardez le nombre près de l’autre classe :

  • Pour un atelier, regardez le côté Reservation. On trouve 0..* réservations.
  • Pour une réservation, regardez le côté Atelier. On trouve 1 atelier.

0..* ne signifie pas que les places sont illimitées. Cette multiplicité ne fixe aucune borne supérieure au nombre de réservations liées à un atelier, mais une règle supplémentaire limite les réservations actives à sa capacité. Un atelier de 12 places peut ainsi avoir 12 réservations actives et plusieurs réservations annulées dans son historique.

Les nombres ne se déduisent pas de la forme du lien. Si aucune multiplicité n’est affichée, on ne peut pas conclure que la relation autorise un seul objet, ni qu’elle en autorise plusieurs.

Dessiner son premier modèle

Partons de cette description :

Un atelier possède un titre et une capacité. Une réservation enregistre un montant en centimes. Chaque réservation concerne un atelier. Un atelier peut n’avoir encore aucune réservation.

  1. Donnez une question au schéma. Ici, on veut montrer comment les réservations se rattachent aux ateliers, pas l’ensemble de l’application.
  2. Choisissez les concepts utiles. Atelier et Reservation suffisent pour cette question. La réservation porte ses propres données, on lui donne donc un rectangle.
  3. Placez les données sur le concept qu’elles décrivent. La capacité appartient à l’atelier. Le montant enregistré appartient à la réservation, car deux personnes peuvent réserver le même atelier à des tarifs différents.
  4. Tracez une association, puisque la description n’impose ni propriété exclusive ni suppression commune. Choisissez les multiplicités en écrivant une phrase dans chaque sens. Un atelier a zéro ou plusieurs réservations, et une réservation concerne exactement un atelier.
  5. Ajoutez les opérations dont on veut discuter. On les omet ici, puisque la question porte sur les données et leurs relations.

Voici le texte Mermaid à saisir pour ce modèle. Il peut se conserver dans un fichier Markdown ou se tester dans l’éditeur Mermaid.

classDiagram
  direction LR
  class Atelier {
    titre : string
    capacite : number
  }
  class Reservation {
    montantCentimes : number
  }
  Atelier "1" -- "0..*" Reservation : concerne

Ce texte produit le diagramme suivant. La visibilité des attributs n’est pas précisée, car on décrit ici les ateliers et les réservations, sans décider de leur organisation dans le code.

classDiagram direction LR accTitle: Un modèle d'ateliers et de réservations accDescr: Atelier porte le titre et la capacité. Reservation porte le montant en centimes. Chaque réservation concerne un atelier, qui peut recevoir zéro ou plusieurs réservations. class Atelier { titre : string capacite : number } class Reservation { montantCentimes : number } Atelier "1" -- "0..*" Reservation : concerne

Il faut compléter le diagramme par les règles que les attributs et les liens n’expriment pas. Une réservation est refusée quand l’atelier est complet, et son montant reste inchangé si le tarif de l’atelier évolue ensuite.

Ce modèle ne prescrit ni une table de base de données par rectangle ni une classe TypeScript par concept. L’exercice du cours représente ces données avec des objets. Le choix de leur organisation dans le programme vient après la clarification des règles.

À vous de compléter

Le modèle précédent ne traite pas encore l’annulation. Complétez-le avec les règles suivantes, qui demandent de conserver l’historique plutôt que de supprimer les réservations annulées :

  • Un participant possède une adresse de courriel.
  • Chaque réservation désigne un seul participant.
  • Un participant peut exister sans avoir réservé et réserver plusieurs ateliers.
  • L’annulation d’une réservation conserve son historique et son lien avec le participant.

Dessinez Participant avec son attribut et son lien vers Reservation. Ajoutez les multiplicités, puis comparez avec le schéma suivant.

Une proposition

classDiagram direction TB accTitle: Une réservation relie un participant à un atelier accDescr: Chaque réservation concerne exactement un atelier et un participant. Un atelier comme un participant peut être lié à zéro ou plusieurs réservations. class Atelier { titre : string capacite : number } class Reservation { montantCentimes : number statut : string } class Participant { courriel : string } Atelier "1" -- "0..*" Reservation : concerne Participant "1" -- "0..*" Reservation : effectue

Les associations relient chaque réservation à son participant et à son atelier. Le nombre un près du participant signifie qu’une réservation désigne exactement une personne. La multiplicité près de la réservation autorise une personne à n’avoir aucune réservation ou à en avoir plusieurs.

Le statut permet ici de distinguer une réservation active d’une réservation annulée. L’annulation change ce statut, mais conserve l’enregistrement et ses deux liens. Elle libère une place sans effacer l’historique. Un changement d’état ne nécessite donc pas une composition UML.

Une association suffit pour les règles demandées, qui n’imposent ni propriété exclusive ni suppression commune. Le dessin ne décide pas ce qui doit se produire si l’on supprime un participant ou un atelier. Si cette opération devient nécessaire, il faudra préciser comment conserver ou supprimer les réservations concernées.

Une classe, une interface ou une relation supplémentaire doit aider à répondre à la question posée. On peut enrichir le modèle à mesure que les règles se précisent. La documentation Mermaid des diagrammes de classes fournit la syntaxe, tandis que la spécification UML de l’OMG définit le langage.

Choisir une création avec Factory

Dans la séance Strategy, un appel crée un TarifNormal ou un TarifSolidaire et fournit cet objet à reserver.

Si plusieurs parties du programme doivent choisir quel objet créer à partir du nom d’un tarif, ce choix risque d’être répété. Une fabrique peut le regrouper dans une fonction qui renvoie l’objet attendu.

En TypeScript, une fonction qui renvoie un objet couvre la plupart des besoins de création, c’est pourquoi ce chapitre fait partie des approfondissements. Sa section sur la fabrique qui garde une configuration explique pourtant la fermeture qu’utilise le store du chapitre Frameworks 2.

Lire une fonction qui crée un objet

Commencez par cet exemple JavaScript, sans classe ni types TypeScript.

function creerParticipant(nom) {
	return {
		nom: nom,
		statut: "inscrit"
	};
}

const lina = creerParticipant("Lina");
const sam = creerParticipant("Sam");
console.log(lina.nom); // Lina
console.log(sam.nom); // Sam
console.log(lina === sam); // false

nom est le paramètre reçu par la fonction. Dans nom: nom, le mot de gauche nomme la propriété de l’objet. Celui de droite désigne la valeur du paramètre. return renvoie l’objet à l’appelant. Comme chaque appel crée un objet distinct, la dernière comparaison indique que les deux variables ne désignent pas le même objet.

On appelle cette fonction une fabrique parce qu’elle crée les objets.

Lire une fabrique qui garde une configuration

Une fabrique peut aussi créer un objet dont les méthodes conservent un paramètre de configuration. Dans l’exemple suivant, ce paramètre est le préfixe ajouté à chaque salutation.

function creerAccueil(prefixe) {
	return {
		saluer: function (nom) {
			return prefixe + nom;
		}
	};
}

const accueil = creerAccueil("Bienvenue ");
console.log(accueil.saluer("Lina")); // Bienvenue Lina
console.log(accueil.saluer("Sam")); // Bienvenue Sam

creerAccueil("Bienvenue ") crée l’objet une fois. Les deux appels à saluer utilisent le même préfixe. La méthode garde accès au paramètre après la fin de l’appel à creerAccueil. Ce mécanisme s’appelle une fermeture, ou closure.

Factory Pattern sur Patterns.dev montre ce mécanisme avec createLogger et sa méthode log.warn.

Essayer une création liée aux tarifs

Dans une copie du corrigé des tarifs, une création directe peut s’écrire ainsi.

const tarifConfigure = new TarifSolidaire();
assert.equal(tarifConfigure.appliquer(2001), 1001);

Si plusieurs points d’entrée, par exemple un import et un formulaire, doivent choisir le même tarif à partir de son nom, cette sélection risque d’être répétée. Comparez alors la création directe à une fabrique simple qui regroupe le choix de la classe.

function creerTarif(nom: "normal" | "solidaire"): Tarif {
	if (nom === "solidaire") return new TarifSolidaire();
	return new TarifNormal();
}

const tarifImport = creerTarif("solidaire");
assert.equal(tarifImport.appliquer(2001), 1001);
assert.equal(creerTarif("normal").appliquer(2001), 2001);
assert.equal(creerTarif("solidaire").appliquer(0), 0);

Dans cet exemple, l’objet créé implémente Tarif. La fonction creerTarif regroupe le choix de la classe et la création de cet objet. L’appelant connaît seulement le nom du tarif, par exemple lu dans un fichier d’import. Il reçoit un objet qu’il peut transmettre avec inscriptions.reserver(2, "mia@example.test", tarifImport). Pour essayer cet appel, assurez-vous que l’atelier Photo possède encore une place libre.

Les classes TarifNormal et TarifSolidaire existent déjà dans le corrigé. Tant qu’un seul appel choisit le tarif, écrire directement new TarifSolidaire() évite d’ajouter une fonction.

Distinguer les noms des sources

La fonction précédente est une fabrique simple, mais toutes les sources n’utilisent pas le mot Factory pour le même mécanisme. Avec Factory Method, des sous-classes redéfinissent la création des objets. Abstract Factory permet plutôt de créer des familles d’objets conçus pour fonctionner ensemble. Le comparatif des fabriques détaille ces différences.

S’entraîner et vérifier sa compréhension

Construisez des créatures pour pratiquer les classes, l’encapsulation et les interfaces. Vous pourrez ensuite essayer les projets du restaurant ou du panier. Des questions avec leurs réponses permettent aussi de revoir les notions du cours.

Les mini-exercices peuvent se faire après les fondations. Les questions sur les choix de conception viennent après les séances concernées. Chaque correction des trois mini-exercices est un programme TypeScript autonome. On peut copier une correction complète dans creature.ts, puis l’exécuter avec node creature.ts sous Node.js 24. Les assertions vérifient les résultats attendus. Le programme se termine sans message si elles réussissent toutes, et s’arrête avec une erreur si l’une d’elles échoue.

Construire des créatures en trois étapes

Étape 1, créer deux instances

On écrit une classe Creature dont le constructeur reçoit un nom. Chaque instance conserve ce nom et commence avec 100 points d’énergie. Le nom est déclaré readonly, tandis que l’énergie est un champ JavaScript privé #energie, lisible par un getter energie. Une méthode sePresenter() renvoie une phrase qui contient le nom. On ne demande pas encore de modifier l’énergie.

On crée ensuite une grenouille nommée René et un poisson nommé Mina. Leurs présentations doivent être différentes, leurs énergies doivent toutes deux valoir 100 et les variables doivent désigner deux objets distincts. Vérifiez ces résultats avec des assertions.

Comparer avec une correction expliquée
import assert from "node:assert/strict";

class Creature {
	readonly nom: string;
	#energie = 100;

	constructor(nom: string) {
		this.nom = nom;
	}

	get energie(): number {
		return this.#energie;
	}

	sePresenter(): string {
		return `Je m'appelle ${this.nom}.`;
	}
}

const grenouille = new Creature("René");
const poisson = new Creature("Mina");
assert.equal(grenouille.sePresenter(), "Je m'appelle René.");
assert.equal(poisson.sePresenter(), "Je m'appelle Mina.");
assert.equal(grenouille.energie, 100);
assert.equal(poisson.energie, 100);
assert.notEqual(grenouille, poisson);

Le constructeur copie son paramètre dans this.nom. Chaque new initialise aussi son propre champ #energie. L’accès grenouille.energie appelle le getter, sans donner accès au champ privé. readonly empêche les réaffectations du nom dans le code TypeScript vérifié, sans constituer une protection de sécurité à l’exécution.

Les deux objets sont distincts parce qu’on a exécuté deux fois new. Avec const poisson = grenouille, les deux variables désigneraient au contraire le même objet. Changer le nom de la variable ne crée pas une nouvelle créature.

Étape 2, protéger l’énergie

Reprenez Creature de l’étape 1. Son énergie doit rester comprise entre 0 et 100 après chaque action. C’est son invariant.

On ajoute avancer(): boolean. Cette méthode retire 10 points seulement si la créature en possède au moins 10. Elle renvoie true après un déplacement. Si l’énergie est insuffisante, elle renvoie false et laisse l’énergie inchangée. On ajoute aussi seReposer(): void, qui rend 20 points sans dépasser 100. Le nom et la présentation ne changent pas.

On doit pouvoir déplacer René dix fois pour atteindre 0. Le onzième appel doit être refusé en laissant l’énergie à 0. Un repos doit ensuite la ramener à 20, puis des repos supplémentaires doivent l’augmenter sans dépasser 100. Pendant ces opérations, Mina doit rester à 100. Ces attentes vérifient les limites et l’indépendance des instances.

Comparer avec la correction et ses contrôles aux limites
import assert from "node:assert/strict";

class Creature {
	static readonly energieMax = 100;
	readonly nom: string;
	#energie = Creature.energieMax;

	constructor(nom: string) {
		this.nom = nom;
	}

	get energie(): number {
		return this.#energie;
	}

	sePresenter(): string {
		return `Je m'appelle ${this.nom}.`;
	}

	avancer(): boolean {
		if (this.#energie < 10) return false;
		this.#energie -= 10;
		return true;
	}

	seReposer(): void {
		this.#energie = Math.min(Creature.energieMax, this.#energie + 20);
	}
}

const grenouille = new Creature("René");
const poisson = new Creature("Mina");
for (let pas = 0; pas < 10; pas++) {
	assert.equal(grenouille.avancer(), true);
}
assert.equal(grenouille.energie, 0);
assert.equal(grenouille.avancer(), false);
assert.equal(grenouille.energie, 0);
assert.equal(poisson.energie, 100);

grenouille.seReposer();
assert.equal(grenouille.energie, 20);
for (let repos = 0; repos < 5; repos++) grenouille.seReposer();
assert.equal(grenouille.energie, 100);
grenouille.seReposer();
assert.equal(grenouille.energie, 100);

Le contrôle précède la soustraction. Il laisse donc l’objet intact quand l’énergie est insuffisante. Math.min choisit la plus petite valeur entre le maximum et l’énergie augmentée, ce qui empêche le repos de dépasser 100.

#energie est propre à chaque instance, alors que energieMax est un membre static consulté sur la classe. Comme la classe ne propose aucun setter d’énergie, le code extérieur doit passer par les deux opérations prévues pour la modifier. Ces contrôles suffisent aux actions définies ici, qui ne reçoivent pas de quantité extérieure. Si on ajoute un gain d’énergie fourni par l’utilisateur, il faudra valider cette nouvelle entrée.

Étape 3, demander une capacité à un collaborateur

Reprenez Creature de l’étape 2 pour la faire collaborer avec une mare.

On décrit dans une interface Deplacable la capacité attendue par la mare, avec une méthode avancer(): boolean. Elle renvoie true après un déplacement. En cas de refus, elle renvoie false et laisse son état inchangé. Les créatures conservent en plus leurs règles d’énergie. La classe Creature annonce qu’elle satisfait cette interface. On écrit ensuite une classe Mare, qui reçoit des Deplacable dans son constructeur. Sa méthode faireUnTour(): number demande une seule fois à chacun d’avancer, puis renvoie le nombre de déplacements réussis.

La mare doit pouvoir faire avancer deux créatures sans lire leur énergie. Elle doit aussi accepter l’instance d’une classe indépendante, Nenuphar, qui possède la même méthode et refuse toujours de bouger. Avec René, Mina et ce nénuphar, un tour doit ainsi renvoyer 2 et laisser les deux créatures à 90. Après dix tours au total, elles sont à 0 et le tour suivant doit renvoyer 0.

Comparer avec une collaboration qui dépend d'une capacité
import assert from "node:assert/strict";

interface Deplacable {
	avancer(): boolean;
}

class Creature implements Deplacable {
	static readonly energieMax = 100;
	readonly nom: string;
	#energie = Creature.energieMax;

	constructor(nom: string) {
		this.nom = nom;
	}

	get energie(): number {
		return this.#energie;
	}

	sePresenter(): string {
		return `Je m'appelle ${this.nom}.`;
	}

	avancer(): boolean {
		if (this.#energie < 10) return false;
		this.#energie -= 10;
		return true;
	}

	seReposer(): void {
		this.#energie = Math.min(Creature.energieMax, this.#energie + 20);
	}
}

class Mare {
	#habitants: Deplacable[];

	constructor(habitants: Deplacable[]) {
		this.#habitants = [...habitants];
	}

	faireUnTour(): number {
		let deplacements = 0;
		for (const habitant of this.#habitants) {
			if (habitant.avancer()) deplacements += 1;
		}
		return deplacements;
	}
}

class Nenuphar {
	avancer(): boolean {
		return false;
	}
}

const grenouille = new Creature("René");
const poisson = new Creature("Mina");
const nenuphar = new Nenuphar();
const habitants = [grenouille, poisson, nenuphar];
const mare = new Mare(habitants);
habitants.length = 0;

assert.equal(mare.faireUnTour(), 2);
assert.equal(grenouille.energie, 90);
assert.equal(poisson.energie, 90);
for (let tour = 0; tour < 9; tour++) assert.equal(mare.faireUnTour(), 2);
assert.equal(grenouille.energie, 0);
assert.equal(poisson.energie, 0);
assert.equal(mare.faireUnTour(), 0);
grenouille.seReposer();
assert.equal(mare.faireUnTour(), 1);
assert.equal(grenouille.energie, 10);

La mare utilise seulement le résultat d’avancer(). Elle n’a donc besoin ni du nom ni du getter d’énergie. implements Deplacable vérifie la classe Creature, mais n’y ajoute aucune méthode. La classe Nenuphar ne déclare pas implements Deplacable, mais ses instances satisfont aussi l’interface par leur structure. Elle n’a pas besoin d’hériter de Creature pour collaborer avec la mare.

Le constructeur copie le tableau des habitants. Modifier le tableau fourni après la construction ne change donc pas la liste interne. Les créatures elles-mêmes restent partagées, ce qui permet de constater leur nouvelle énergie après un tour. On retrouve ici la distinction entre copier un tableau et copier ses objets.

Les habitants avancent l’un après l’autre dans cette simulation, qui ne traite ni météo ni événements. Elle permet de vérifier que la mare utilise le même contrat avec des objets différents. L’interface décrit les types attendus, tandis que les explications et les assertions précisent le comportement à respecter.

Vérifier sa compréhension

1. Où le nom de la créature est-il conservé ?

Dans new Creature("René"), quel rôle jouent le paramètre nom, la propriété this.nom et la méthode sePresenter() ? Que se passe-t-il au second new Creature("Mina") ?

Lire l'explication

Le paramètre reçoit le texte pendant l’appel au constructeur. L’affectation this.nom = nom conserve ce texte dans une propriété de l’objet créé. Après le constructeur, la méthode peut le retrouver par this.nom. Le second new crée un autre objet, initialisé avec Mina. L’appel poisson.sePresenter() utilise cet objet comme this, donc il ne lit pas le nom de René.

2. Deux variables désignent-elles deux objets ?

On écrit const original = { quantite: 2 }, puis const alias = original. Que vaut alias === original ? Après const copie = { ...original }, que vaut copie === original ? const empêche-t-il de modifier alias.quantite ?

Lire l'explication et la limite de la copie

alias === original vaut true, car les deux variables désignent le même objet. copie === original vaut false, car la décomposition a créé un nouvel objet. Modifier alias.quantite modifie donc aussi ce que l’on lit par original.quantite, tandis que modifier le nombre copie.quantite ne modifie pas l’original.

const empêche de réaffecter la variable, sans figer l’objet. La copie reste superficielle, car elle ne recrée pas les objets imbriqués. Si une propriété contient un tableau, l’objet original et sa copie peuvent donc encore partager ce tableau. Pour obtenir true avec ===, il suffit que les deux variables désignent le même objet, comme alias et original.

3. Un champ privé suffit-il à protéger une règle ?

Une créature cache #energie, mais propose un setter qui affecte directement le nombre reçu. Le code appelant lui donne -50. L’encapsulation protège-t-elle encore l’invariant ? Que changerait le seul mot-clé TypeScript private ?

Lire l'explication

L’invariant est rompu, même si le champ est caché. L’opération publique accepte une valeur interdite et la conserve. Il faut valider chaque modification possible ou ne proposer que les actions qui maintiennent la règle, comme avancer() et seReposer().

private réserve l’accès dans le code TypeScript vérifié, puis disparaît du JavaScript exécuté. #energie contrôle aussi l’accès au champ à l’exécution. Aucun des deux ne corrige un setter qui accepte une énergie négative. La protection d’accès et la validation des actions remplissent deux rôles différents.

4. Faut-il un ancêtre commun pour faire avancer la mare ?

La mare appelle seulement avancer(): boolean. Un robot possède cette méthode, mais ne partage ni le nom ni l’énergie des créatures. Doit-il hériter de Creature ? Que fournit réellement implements Deplacable ?

Lire l'explication

Le robot peut satisfaire Deplacable sans hériter de Creature. L’interface exprime ce dont la mare a besoin, tandis que implements vérifie la présence de la méthode et la compatibilité de ses types. Il n’en fournit pas le code.

Un héritage ou une classe abstraite peut convenir quand les sous-classes doivent réellement partager un état et du code. Ici, imposer les données des créatures au robot n’aide pas la mare. Il faut surtout conserver le contrat d’avancer(), y compris le sens du résultat et les effets d’un refus.

5. Quand une condition suffit-elle pour un tarif ?

Un programme calcule deux tarifs stables au même endroit et ne prévoit aucune règle fournie de l’extérieur. Peut-on conserver un if ? Dans quel cas passer un objet de tarif à reserver deviendrait-il utile ?

Lire l'explication

Une condition peut suffire à ce besoin. Elle garde les deux cas près du calcul et reste facile à vérifier. La présence d’un if ne signale pas, à elle seule, un défaut de conception.

Passer un objet de tarif devient intéressant lorsque plusieurs règles doivent être choisies indépendamment de la réservation, comparées ou remplacées dans les tests. La séance Strategy étudie ce changement précis.

6. Créer un objet et calculer un tarif sont-ils le même choix ?

Une configuration indique quel fournisseur de notification utiliser. Ailleurs, un tarif calcule un montant à partir d’un prix. Quel problème relève d’une fabrique et quel problème peut relever d’une stratégie ? Faut-il systématiquement créer une hiérarchie de classes ?

Lire l'explication

Une fabrique peut regrouper la création et la configuration du fournisseur. L’appelant demande alors un objet prêt à utiliser sans reproduire les détails de sa construction. Une stratégie porte le calcul interchangeable, par exemple un objet TarifSolidaire dont la méthode appliquer transforme un prix en montant.

Si un seul fournisseur se construit en une ligne à un seul endroit, un appel direct peut suffire. Si une fonction avec une condition suffit à choisir entre deux objets à créer, elle peut déjà jouer le rôle d’une fabrique. La séance Factory distingue cette forme du Factory Method GoF. Aucune de ces décisions n’impose une hiérarchie de classes.

7. Retirer une vue suffit-il à la désabonner ?

Une vue s’abonne aux réservations, puis son HTML disparaît. Le mécanisme d’abonnement conserve encore sa fonction. Que doit faire le code qui retire la vue ? Un échec du faux courriel doit-il annuler la réservation déjà enregistrée ?

Lire l'explication

Le code qui retire la vue doit aussi retirer son abonnement. Dans la séance Observer, il appelle la méthode de désabonnement avec l’observateur de cette vue. Retirer le HTML ne supprime pas la référence conservée par le mécanisme d’événements. On vérifie donc qu’après le désabonnement, les événements suivants ne déclenchent plus la réaction de cette vue.

Une réservation enregistrée et un courriel envoyé sont deux résultats distincts. Dans notre séance Observer, on garde l’erreur de réaction séparément et on poursuit les autres notifications. Au début de l’envoi d’un événement, le programme copie la liste des abonnés. Un désabonnement pendant cet envoi ne change donc pas cette copie. L’abonné peut encore recevoir l’événement en cours, mais ne recevra plus les suivants.

8. Une méthode présente respecte-t-elle forcément Liskov ?

Un type promet une méthode annuler() qui annule toute réservation active. Une sous-classe conserve cette méthode, mais lance toujours une erreur « Non annulable ». L’appelant peut-il remplacer une réservation par cette sous-classe sans revoir son code ?

Lire l'explication et une correction possible

La méthode existe, mais sa promesse n’est plus tenue. L’appelant qui pouvait annuler toute réservation active doit maintenant reconnaître la sous-classe et contourner le refus. Le remplacement rompt donc le contrat donné dans la question.

La correction du principe de Liskov réserve le contrat d’annulation aux objets capables de le tenir et garde la consultation accessible aux autres réservations. Si l’annulation peut normalement échouer, le contrat doit annoncer ses conditions et ses résultats dès le départ. Une erreur prévue par un contrat n’est pas automatiquement une violation de Liskov.

Réutiliser les acquis dans un autre projet

Ces projets facultatifs permettent de reprendre les mêmes questions dans un restaurant ou un panier, avec des demandes fonctionnelles à satisfaire. On choisit un projet et on commence par quelques données en mémoire. Les montants sont des nombres entiers de centimes, les quantités sont des entiers strictement positifs et chaque opération refusée laisse l’état intact.

Projet facultatif, suivre une commande au restaurant

Un menu contient des plats et leurs prix. On prépare une commande en ajoutant des plats et des quantités, puis on la transmet à la cuisine. Les statuts se suivent dans cet ordre : brouillon, transmise, en préparation, puis servie. Une commande transmise n’accepte plus de modification de ses lignes dans cette première version.

Le montant est figé au moment de la transmission. Modifier ensuite un prix du menu ne change donc pas le récapitulatif de cette commande. On commence sans réduction. Une seconde règle de calcul peut être ajoutée si on souhaite comparer les choix de la séance Strategy.

On simule la cuisine et l’écran de suivi par un journal en mémoire. Le programme produit une notification uniquement lorsqu’il accepte un changement de statut ou une modification de la commande. Un plat inconnu, une quantité invalide ou un changement de statut interdit ne produit ni modification ni notification de réussite.

Essayez les cas suivants.

  • Deux plats à 1 200 centimes et un plat à 800 centimes donnent un montant de 3 200 centimes lors de la transmission.
  • Le même récapitulatif vaut encore 3 200 après un changement du prix du premier plat dans le menu.
  • Une quantité de zéro et une tentative de passer directement du statut « brouillon » au statut « servie » laissent l’état et le journal inchangés.
  • La cuisine reçoit une seule notification lors de la première transmission réussie. Répéter cette demande ne crée pas une seconde commande.
  • L’écran reçoit les changements autorisés tant qu’il est présent. S’il utilise des abonnements, son retrait coupe les événements suivants.

Ce programme ne traite ni paiements, ni taxes, ni véritable envoi à une cuisine, ni stockage durable. Les contrôles portent sur une simulation séquentielle dans un seul processus.

Projet facultatif, conserver les quantités d’un panier

Un catalogue permet de filtrer des produits par catégorie, prix et disponibilité. On ajoute des produits au panier, on change leur quantité ou on retire une ligne. Le filtrage doit laisser le catalogue intact.

À son premier ajout, une ligne conserve l’identifiant du produit et son prix unitaire en centimes. Ajouter à nouveau le même produit augmente sa quantité en gardant ce prix. Une modification ultérieure du catalogue ne change pas le prix de cette ligne. La confirmation produit un récapitulatif indépendant du panier, avec les quantités et les montants retenus à cet instant.

On peut ajouter, en option, l’annulation de la dernière action réussie. On doit alors restaurer exactement l’état qu’elle a modifié. Annuler l’ajout d’une unité à une ligne qui en contenait deux doit rétablir deux unités, sans supprimer la ligne. Une action refusée ne remplace pas la dernière action annulable. On définit aussi ce qui se passe si l’on demande une seconde annulation sans nouvelle action.

Essayez les cas suivants.

  • Ajouter deux unités d’un produit à 1 200 centimes donne une ligne de quantité 2 et un total de 2 400 centimes.
  • Ajouter une unité supplémentaire donne une quantité de 3 et un total de 3 600. Si l’option d’annulation existe, annuler ce dernier ajout revient à 2 et 2 400.
  • Une quantité négative, nulle ou décimale est refusée sans modifier les lignes ni le total.
  • Changer le prix du catalogue laisse le prix de la ligne inchangé. Modifier le panier après confirmation laisse aussi le récapitulatif inchangé.
  • Modifier un tableau de lignes obtenu pour l’affichage ne permet pas de contourner les règles du panier.

Cette version simule la disponibilité et n’effectue ni réservation réelle du stock, ni paiement, ni persistance. Les contrôles vérifient aussi que les prix et les totaux restent des entiers de centimes représentables exactement par JavaScript.

Glossaire

Ce glossaire permet de retrouver un terme rencontré dans le cours et d’accéder au chapitre qui l’explique. Les termes anglais reprennent les noms utilisés dans la documentation et le code. Lorsqu’une ligne donne plusieurs noms, ils désignent ici la même notion.

Objets et relations

TermeSensChapitre
ObjetValeur qui regroupe des propriétés et des opérations. En JavaScript, un objet peut être créé sans classe.Objets et classes
ClasseDéfinition utilisée pour créer des objets avec une structure et des méthodes communes. Chaque objet créé possède son propre état.Objets et classes
InstanceObjet créé à partir d’une classe. Deux instances de Creature peuvent avoir des énergies différentes.Objets et classes
RéférenceValeur qui permet de désigner un objet. Après const autre = creature, les deux variables désignent le même objet et observent ses modifications.Objets et références
Copie superficielleNouvel objet ou tableau qui reprend les valeurs du premier niveau. Les objets imbriqués restent partagés. { ...reservation } suffit pour isoler les champs primitifs des réservations du cours.Objets et références
Attribut, champDonnée nommée qui participe à l’état d’un objet ou d’une classe. Le champ privé #energie conserve l’énergie d’une créature.Encapsulation
PropriétéÉlément nommé accessible sur un objet. creature.nom lit une donnée, tandis que creature.energie appelle ici un getter. Les champs JavaScript privés écrits avec # ne se lisent pas comme des propriétés publiques.Lire une classe
MéthodeFonction accessible par un objet ou une classe. Une méthode d’instance peut lire ou modifier l’état de l’objet qui la reçoit.Objets et classes
GetterAccesseur qui exécute du code lors de la lecture d’une propriété. Il permet notamment d’exposer un champ privé sans autoriser son affectation directe.Encapsulation
SetterAccesseur qui exécute du code lors de l’affectation d’une propriété. Il peut vérifier la valeur reçue avant de modifier l’état de l’objet.Encapsulation
EncapsulationOrganisation qui réserve l’accès ou la modification d’un état à des opérations contrôlées. Ces opérations protègent les règles de l’objet.Encapsulation
HéritageRelation par laquelle une classe reprend les membres d’une classe parente et peut les compléter ou les redéfinir. L’utilisation comme sous-type exige aussi le respect du contrat du parent.Composition et héritage
CompositionOrganisation dans laquelle un objet utilise d’autres objets pour effectuer une partie de son travail. En UML, la composition désigne plus précisément une relation tout-partie où une partie appartient au plus à un composite à la fois.Composition et héritage
UMLLangage de modélisation qui définit plusieurs sortes de diagrammes. Un diagramme de classes décrit des types, leurs membres et leurs relations.Diagrammes de classes
MultiplicitéNombre d’instances qui peuvent participer à une relation UML pour une instance à l’autre extrémité. 0..* autorise aucune, une ou plusieurs instances.Diagrammes de classes

Lire les mots d’une classe

TermeSensChapitre
Constructeur, constructorCode qui initialise une instance pendant sa création. Dans constructor(nom: string), nom reçoit l’argument fourni à new.Lire une classe
newOpérateur utilisé ici pour créer une instance d’une classe et appeler son constructeur. Deux appels à new Creature() créent deux objets distincts.Lire une classe
thisValeur qui désigne l’objet utilisé pour cet appel. Dans grenouille.avancer(), this désigne grenouille pendant l’exécution de cette méthode d’instance.Lire une classe
publicVisibilité qui autorise l’utilisation d’un membre depuis le code extérieur à la classe. En TypeScript, un membre sans modificateur de visibilité est public par défaut.Lire une classe
privateModificateur TypeScript qui interdit l’accès à un membre en dehors de sa classe pendant la vérification des types. Les champs JavaScript écrits avec # disposent d’un contrôle d’accès à l’exécution.Lire une classe
protectedModificateur TypeScript qui réserve l’accès à un membre au code de sa classe et de ses sous-classes. Il n’autorise pas les appelants extérieurs à l’utiliser.Lire une classe
staticModificateur qui place un membre sur la classe elle-même. Creature.energieMax se lit sur la classe, tandis que grenouille.energie se lit sur une instance.Objets et classes
readonlyModificateur TypeScript qui limite les affectations d’une propriété. Il ne gèle pas l’objet à l’exécution et ne rend pas ses objets imbriqués immuables.Lire une classe
extendsMot-clé qui déclare ici qu’une classe hérite d’une autre. Il permet aussi à une interface TypeScript d’étendre un autre contrat, sans lui ajouter de code exécuté.Héritage et contrats
superMot-clé qui donne accès au constructeur ou à une méthode de la classe parente. Dans le constructeur d’une sous-classe, super(nom) initialise la partie héritée avec ce nom.Héritage et contrats
implementsMot-clé TypeScript qui demande au compilateur de vérifier qu’une classe satisfait un contrat. Il ne fournit pas le code des méthodes annoncées.Héritage et contrats
Redéfinition, overrideNouvelle version d’un membre hérité, écrite dans une sous-classe. Le mot-clé TypeScript override fait vérifier que ce membre existe dans la classe parente.Héritage et contrats
Classe abstraite, abstractClasse TypeScript qui sert de base à des sous-classes et que le compilateur interdit d’instancier directement. Elle peut associer du code partagé et des opérations abstraites.Interface et classe abstraite
Méthode abstraiteOpération déclarée sans corps dans une classe abstraite. Une sous-classe concrète doit fournir le code correspondant si elle n’en hérite pas déjà.Interface et classe abstraite

Contrats et types

TermeSensChapitre
ContratEnsemble des opérations, conditions et garanties sur lesquelles un appelant peut compter. Une signature de méthode décrit une partie de ce contrat.Contrats et polymorphisme
InterfaceDescription des opérations ou des données qu’un code rend accessibles à d’autres codes. En TypeScript, le mot-clé interface décrit les membres attendus et leurs types.Contrats et polymorphisme
AbstractionDescription des capacités utiles à un appelant, sans tous les détails de leur réalisation. Un contrat de stockage permet ainsi de réserver sans connaître la base de données.Contrats et polymorphisme
InvariantRègle vraie à la création d’un objet et préservée après ses opérations publiques. L’énergie d’une créature reste par exemple entre 0 et 100.Encapsulation
PréconditionCondition que l’appelant doit respecter avant une opération. Une opération de retrait peut exiger un solde suffisant.Substitution de Liskov
PostconditionGarantie sur le résultat ou l’état après une opération, lorsque ses conditions d’utilisation sont respectées. Une annulation réussie garantit par exemple que la place réservée est de nouveau disponible.Substitution de Liskov
Sous-typeType utilisable là où un type plus général est attendu. Un sous-type comportemental respecte aussi les conditions et les garanties de ce type général.Substitution de Liskov
PolymorphismePossibilité pour un appelant d’utiliser un même contrat avec plusieurs implémentations. Chaque implémentation fournit le comportement attendu à sa manière.Contrats et polymorphisme
Duck typing, typage canardStyle de programmation qui utilise les opérations d’un objet sans exiger une classe précise. La présence d’une méthode ne suffit pas à prouver que l’objet respecte son contrat comportemental.Duck typing
Typage structurelSystème qui détermine la compatibilité des types en comparant leurs membres et les types de ces membres. TypeScript utilise cette approche pour vérifier les objets avant l’exécution.Typage structurel
Typage nominalSystème où la compatibilité dépend de l’identité des types et de relations déclarées. Deux types de même structure ne deviennent pas compatibles pour cette seule raison.Typage nominal
SOLIDGroupe de cinq principes sur les responsabilités, l’extension, la substitution, les interfaces et les dépendances. Ils aident à discuter une conception et ses conséquences.Principes SOLID
Responsabilité unique, SRPPrincipe qui regroupe le code répondant à une même raison de changement, liée aux règles de l’activité. Plusieurs méthodes peuvent contribuer à cette responsabilité.Responsabilité unique
OCPPrincipe ouvert à l’extension et fermé à la modification. Une variation prévue peut s’ajouter derrière un contrat stable, sans réécrire le traitement qui l’utilise.Extension
Substitution de Liskov, LSPPrincipe qui exige d’un sous-type qu’il préserve les promesses du type attendu par l’appelant. Une signature compatible ne suffit pas à garantir ce comportement.Substitution
Ségrégation des interfaces, ISPPrincipe qui donne à chaque client un contrat adapté aux opérations dont il a besoin. Un écran de consultation n’a pas besoin d’exiger les opérations d’administration.Interfaces
Inversion des dépendances, DIPPrincipe qui fait dépendre les détails techniques d’un contrat adapté aux règles métier. Fournir un objet en paramètre ne démontre pas à lui seul cette direction des dépendances.Dépendances

Fonctions et organisation du code

TermeSensChapitre
ParamètreNom déclaré dans la définition d’une fonction ou d’une méthode pour recevoir une valeur lors de l’appel. Dans la méthode reserver(atelierId: number, email: string), email est un paramètre.Application de réservation
ArgumentValeur fournie à une fonction ou à une méthode lors d’un appel. Dans inscriptions.reserver(1, "lea@example.test"), 1 et la chaîne sont des arguments.Application de réservation
Callback, fonction de rappelFonction transmise à un autre code pour que ce code l’appelle au moment prévu. Elle peut être appelée pendant le traitement courant ou plus tard.Observer
Closure, fermetureFonction qui garde accès aux variables de l’environnement où elle a été définie, même après la fin de la fonction qui l’a créée. Une fabrique peut ainsi produire une fonction qui conserve le tarif reçu lors de sa création.Factory
Fonction pureFonction dont le résultat dépend uniquement de ses arguments et qui ne modifie aucun état extérieur. Elle peut calculer une remise sans enregistrer une réservation ni envoyer un message.Strategy
ModuleUnité de code qui organise ses déclarations et expose certains éléments aux autres modules. En JavaScript, export et import définissent ces échanges.Frameworks
CohésionDegré auquel les éléments d’un module contribuent à une même tâche ou à un ensemble de tâches liées. Un module qui calcule les tarifs et met en forme des courriels réunit des raisons de changement différentes.Responsabilités
CouplageEnsemble des dépendances et des connaissances qui lient des éléments du code. Connaître les champs propres à un fournisseur lie le code appelant à son format.Responsabilités
AssertionVérification exécutable qui échoue si une condition ou un résultat attendu n’est pas respecté. assert.equal(places, 2) exige que places vaille exactement 2.Application de réservation
Refactoring, refactorisationModification de la structure du code qui conserve son comportement observable. Extraire un calcul dans une fonction peut rendre une règle plus facile à lire et à tester.Refactoriser

Patterns et frameworks

TermeSensChapitre
Strategy, stratégieOrganisation qui permet de choisir une implémentation d’une règle derrière un même contrat. Une règle de réduction peut varier sans modifier le traitement qui l’utilise.Strategy
Observer, observateurOrganisation où un sujet avertit les abonnés d’un événement sans connaître le détail de leurs réactions.Observer
Factory, fabriqueCode qui centralise la création d’objets. Une fabrique simple choisit quoi créer, tandis que Factory Method et Abstract Factory décrivent des organisations plus précises.Factory
Adapter, adaptateurCode qui traduit le contrat d’un service ou d’un format vers celui attendu par l’application. Il isole par exemple les noms de champs propres à une API externe.Adapter
Decorator, décorateurObjet ou fonction qui entoure un comportement pour lui ajouter un traitement tout en conservant le contrat attendu par l’appelant. Il peut par exemple mesurer la durée d’une opération.Decorator
Repository, dépôtAccès à une collection d’objets de l’application, comme les réservations, qui masque les détails de leur enregistrement et de leur lecture dans un stockage. Un dépôt de réservations peut proposer une recherche par identifiant.Repository
Bibliothèque, libraryEnsemble de fonctions ou de composants que l’application appelle pour effectuer une tâche. L’application choisit les moments où elle les utilise.Frameworks
FrameworkCadre logiciel qui organise une partie de l’exécution et appelle le code fourni par l’application aux points prévus. Un framework web peut appeler un gestionnaire quand une requête arrive.Frameworks
Inversion de contrôle, IoCOrganisation où un mécanisme extérieur choisit quand appeler le code de l’application. Les gestionnaires d’événements du navigateur en sont un exemple.Frameworks
Injection de dépendancesFait de fournir à un code les objets ou fonctions dont il a besoin, au lieu de le laisser les créer ou les chercher lui-même. Une fonction de réservation peut ainsi recevoir le dépôt qu’elle utilise.Frameworks 6
Localisateur de services, service locatorObjet central auquel un code demande explicitement les services dont il a besoin. Martin Fowler l’oppose à l’injection, où le code reçoit ses services sans rien demander.Frameworks 6
ComposantMorceau d’interface qui regroupe ses éléments, la façon de les mettre à jour et ses réactions aux actions de la personne. Une ligne d’atelier avec son bouton Réserver forme un composant.Frameworks 3
État de l’interfaceDonnées nécessaires pour dessiner la page à un instant donné, comme les places libres, le courriel saisi et le dernier message.Frameworks 2
StoreObjet qui conserve l’état de l’interface, permet de le remplacer et prévient ses abonnés à chaque changement. Il joue le rôle du sujet d’un Observer.Frameworks 2
RenduTransformation de l’état en éléments affichés. Un rendu complet recalcule toute la page, un rendu ciblé modifie seulement les éléments concernés.Frameworks 2
Délégation d’événementsTechnique qui pose un seul écouteur sur un ancêtre, puis retrouve l’élément cliqué grâce à la propagation des événements. Elle fonctionne aussi pour les éléments créés plus tard.Frameworks 2
Template Method, patron de méthodePattern où une classe mère fixe l’ordre des étapes d’un algorithme et laisse les sous-classes en redéfinir certaines. Les étapes facultatives s’appellent des crochets, comme connectedCallback d’un élément personnalisé.Frameworks 3
SignalConteneur de valeur qui retient les effets qui le lisent et les relance quand sa valeur change. L’abonnement se fait par la lecture, sans appel explicite.Frameworks 4
EffetFonction que le framework relance chaque fois qu’un signal lu pendant sa dernière exécution change. Un effet peut par exemple écrire le nombre de places dans un élément.Frameworks 4
Cycle de vieÉtapes de l’existence d’un composant : création, montage dans la page, mises à jour, puis démontage. Le démontage doit défaire les abonnements et les écouteurs extérieurs.Frameworks 5
PortéeListe des nettoyages à exécuter ensemble, rattachée à un composant ou à une exécution d’effet. Détruire une portée arrête aussi les effets créés dans cette portée.Frameworks 5
DOM virtuelDescription de l’interface sous forme d’objets JavaScript, que React compare avec la description précédente pour ne modifier que les éléments qui diffèrent.Frameworks 7

Ressources et bibliographie

Ces ressources aident à comprendre les exemples TypeScript du cours et à comparer leurs choix de conception. Consultez-les selon vos besoins.

BesoinLecture conseilléeMise en pratique
Comprendre un mot ou une syntaxeGlossaire, puis documentation du langageRetrouver le mécanisme dans un petit exemple.
Comprendre l’intention et la structure d’un patternFiches et schémas de Refactoring.GuruNommer les rôles de notre application et comparer deux solutions.
Lire des variantes JavaScriptPatterns.devSuivre les valeurs et distinguer le mécanisme des syntaxes employées.
Faire évoluer le programmeSituations et séances du parcoursAjouter un tarif, un partenaire ou des notifications.

POO, contrats et SOLID

Pour commencer, suivez cet ordre de lecture :

  1. L’encapsulation en orientée objet, Data Transition Numérique. La boîte noire et l’exemple de voiture illustrent la différence entre opérations publiques et état interne, avec du code Java. Complétez cette lecture avec les règles d’énergie de Creature, car ajouter des getters et des setters ne garantit pas à lui seul un état valide.
  2. Introduction à la programmation orientée objet, partie IV, Eric Sigoillot. Cette partie explique l’encapsulation, l’héritage et le polymorphisme en Pascal objet, à partir de bâtiments et de véhicules.
  3. Bonnes pratiques objet .NET, principes SOLID, Philippe Vialatte. Cet article de 2008 relie cohésion, couplage et encapsulation, puis présente chaque principe avec une définition, une démarche et un exemple C#. Comparez le découpage des responsabilités avec les exemples SOLID du cours. Dans une application, remplacez les requêtes SQL par concaténation par des requêtes paramétrées.

Le cours NSI du lycée Blaise Pascal introduit la POO avec des exemples Python et des diagrammes UML. Il distingue attributs d’instance, attributs de classe et méthodes.

L’article Héritage vs composition de LeanDeep illustre les relations « est un » et « a un » avec des exemples Python et des diagrammes UML. Il complète les fondations. La relation « est un » ne suffit pas à justifier un héritage : la sous-classe doit respecter les promesses de sa classe parente. Pour choisir un losange UML, précisez à quel objet les parties appartiennent et si elles peuvent être partagées.

Le manuel TypeScript sur les classes décrit les champs, les méthodes, la visibilité et l’héritage. La page sur la compatibilité des types explique pourquoi un objet peut respecter une interface sans déclaration explicite.

La référence des classes sur MDN complète la lecture d’une classe pas à pas. La page sur la syntaxe de décomposition explique la copie superficielle produite par { ...objet }. Les exemples de références et de copies montrent pourquoi un objet imbriqué reste partagé.

L’introduction à SOLID de DigitalOcean propose des exemples PHP. La partie Liskov montre comment corriger un retour incompatible. Pour travailler aussi les promesses métier, lisez le contre-exemple et la correction des réservations, puis la partie Liskov de Philippe Vialatte.

La section 2.3 de Principes avancés de conception objet, ENSICAEN, pages 3 et 4 compare les contrats du rectangle et du carré, puis propose une correction par composition. Elle complète l’explication avec les réservations.

Approfondir chaque principe

Le support ENSICAEN propose d’autres exemples pour chaque principe du chapitre SOLID.

PrincipeComplément ENSICAEN
S, responsabilité uniqueSection 2.1, pages 1 et 2
O, ouverture et fermetureSection 2.2, pages 2 et 3
L, substitution de LiskovSection 2.3, pages 3 et 4
I, ségrégation des interfacesSection 2.4, pages 4 et 5
D, inversion des dépendancesSection 2.5, pages 5 et 6

Les textes de Robert C. Martin et de Martin Fowler sont en anglais.

Textes de référence

La présentation de Barbara Liskov au MIT situe ses travaux sur l’abstraction et les langages. Son article avec Jeannette Wing, A Behavioral Notion of Subtyping, formalise le sous-typage comportemental en 1994. Lisez-le après l’exemple du cours sur la substitution.

La définition du duck typing dans le glossaire Python complète la distinction avec le typage structurel TypeScript. Pouvoir appeler une méthode et respecter ses garanties sont deux vérifications différentes.

Robert C. Martin, surnommé Uncle Bob, a contribué à diffuser les principes SOLID. Il précise le lien entre responsabilité unique et acteurs du changement. Son article Solid Relevance rappelle les cinq principes et leur portée au-delà d’un langage particulier.

Diagrammes UML

Le chapitre UML, lire et dessiner un diagramme de classes présente les membres, les relations et les multiplicités, puis propose un exercice. La documentation Mermaid des diagrammes de classes fournit la syntaxe, à essayer dans l’éditeur Mermaid. Pour vérifier un point précis, consultez la spécification UML de l’OMG après le chapitre du cours.

Structures des patterns

Le catalogue de Refactoring.Guru classe les patterns orientés objet par leur intention de création, de structure ou de comportement. Ses fiches présentent Strategy, Observer, Adapter et Decorator. Le comparatif des fabriques distingue le terme général, la fabrique simple et les patterns de création.

La fiche Repository de Martin Fowler rattache ce pattern à une collection d’objets métier.

Patterns JavaScript

Patterns.dev couvre les mécanismes JavaScript, l’organisation des interfaces, le rendu et les performances. Ce périmètre dépasse celui des patterns orientés objet du GoF. Une fonction, un module ou une façon d’organiser les composants peut répondre au même problème sans reproduire une hiérarchie de classes.

  • Observer Pattern détaille l’abonnement, le nettoyage et la distinction avec un bus de publication et d’abonnement.
  • Factory Pattern montre des fonctions de création et leur configuration. Commencez par les deux exemples JavaScript de la fiche Factory, car l’article combine les fabriques avec des closures et plusieurs syntaxes compactes. Ses exemples HTTP et ses types TypeScript avancés servent à approfondir.
  • Module Pattern traite des opérations exportées et de l’état interne d’un module.
  • Container/Presentational Pattern sépare l’obtention des données et leur présentation, avec des exemples React.

Le guide des modules de MDN complète ces lectures avec les mécanismes du langage.

Frameworks et cycle d’exécution

L’article de Fowler sur l’inversion de contrôle explique qui décide quand le code s’exécute. Celui sur l’injection de dépendances décrit comment lui fournir les objets ou fonctions nécessaires.

La documentation React décrit les étapes de rendu et de commit. MDN détaille l’inscription et le retrait d’écouteurs DOM.

L’article de Ralph Johnson et Brian Foote, Designing Reusable Classes, publié en 1988, donne la définition d’origine du framework orienté objet et de l’inversion de contrôle. Le chapitre Frameworks 1 en cite la traduction.

Pour les composants, MDN explique les éléments personnalisés et leurs méthodes de cycle de vie. La fiche Template Method de Refactoring.Guru présente le pattern qu’ils suivent.

Pour les signaux, l’article de Ryan Carniato Building a Reactive Library from Scratch construit une bibliothèque proche de celle du cours. La page de Vue sur la réactivité en profondeur montre le même mécanisme avec des Proxy, et la proposition TC39 décrit un standard possible.

Pour le nettoyage et l’injection, comparez useEffect dans React, onCleanup dans Solid, provide et inject dans Vue, et l’injection de dépendances d’Angular.

Repères visuels

Murmur, projet de oio et mattering, représente des écosystèmes vivants. Sa vue forestière Plot illustre les notions d’état, d’interaction et d’événement dans les fondations.

La capture de Smalltalk montre un navigateur de classes et de méthodes. Dans Personal Distributed Computing: The Alto and Ethernet Software, Butler W. Lampson décrit Smalltalk et présente son environnement à la figure 1.

Livres

  • Erich Gamma, Richard Helm, Ralph Johnson et John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software, Addison-Wesley Professional, 1994. Le catalogue GoF réunit 23 patterns orientés objet et décrit leurs intentions, leurs rôles et leurs conséquences.
  • Eric Freeman et Elisabeth Robson, Head First Design Patterns, 2e édition, O’Reilly, 2020. Il explique les patterns avec des illustrations et des exemples Java. Les mécanismes se transposent à TypeScript, en tenant compte des fonctions et du typage structurel.
  • Martin Fowler et coauteurs, Patterns of Enterprise Application Architecture, 2002. Il traite de l’architecture des applications d’entreprise, notamment de la séparation entre métier et persistance avec Repository.
  • Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship, 1re édition, 2008. Le livre traite notamment du nommage, des fonctions et des tests. Pour évaluer une recommandation, comparez les changements qu’elle facilite à son coût pour la lecture et les tests.

Pour pratiquer

Les exercices du parcours proposent des créatures à programmer, puis un restaurant et un panier. L’application de réservation sert de point de départ aux mises en situation.