📐 Construire une app en JS
45. Classes ou fonctions ?

Classes ou fonctions ?

Le chapitre 3 avait laissé la question ouverte :

Il n'y a pas de réponse définitive. Chacun code avec le paradigme qu'il maîtrise le mieux. En revanche, c'est quand même préférable de conserver de la cohérence sur la façon de faire. Mais ne décidons pas maintenant. Laissons les tests nous guider.

Quarante-deux chapitres plus tard, faisons l'inventaire de ce vers quoi ils nous ont guidés.

Ce que nous avons écrit sans le décider

briqueforme retenue
commandes (book, login, cancelBooking)fonctions qui retournent une fonction
valeurs (CalendarDay, Stay, Occupancy, Result)classes
entités (Booking)classes
règles (canHost, canBeCancelled)fonctions
repositories (MemoryBookingRepository, SQLBookingRepository)classes
fournisseurs (dateProvider, idProvider, passwords)objets littéraux
notifications, événementsfonctions de fabrication, objets plats
App, Contextclasses

C'est du code mixte. Et pourtant, personne n'a jamais tranché en réunion.

Ce n'est pas de l'incohérence : c'est un critère qui s'est appliqué tout seul, chapitre après chapitre. Formulons-le.

Une classe quand un état et un comportement doivent rester indissociables. Une fonction quand il n'y a rien à garder entre deux appels.

Passons l'inventaire à ce filtre.

CalendarDay détient #iso et refuse qu'on le change. Booking détient son statut et n'autorise qu'une transition. MemoryBookingRepository détient le tableau des réservations. SQLBookingRepository détient sa connexion. Context accumule utilisateur, erreur, jeton, événements.

Toutes ont un état à protéger. Toutes sont des classes.

canHost(accommodation, occupancy) ne retient rien : mêmes entrées, même sortie, et l'appel suivant ne sait rien du précédent. systemDateProvider.today() ne retient rien non plus.

Ce sont des fonctions. Les emballer dans une classe n'aurait rien ajouté — sinon un new.

La classe qui n'en est pas une

Voici le contre-exemple qu'on rencontre dans tous les projets :

class DoStuffService {
  private id: string;
  private value: number;
 
  constructor(id: string, value: number) {
    this.id = id;
    this.value = value;
  }
 
  execute() {
    return `${this.id} : ${this.value}`;
  }
}
 
new DoStuffService("snapshot-1", 34).execute();

Et son équivalent :

function doStuff(id: string, value: number) {
  return `${id} : ${value}`;
}
 
doStuff("snapshot-1", 34);

Onze lignes contre trois. Même résultat, exactement.

Regardez ce que la classe apporte : elle stocke les paramètres dans des champs pour les relire immédiatement dans la seule méthode qui existe. L'objet n'a aucune durée de vie — il est construit, appelé, jeté.

Le symptôme est facile à reconnaître, et il ne trompe jamais :

Un objet qui n'est appelé qu'une fois, juste après sa construction, est une fonction déguisée.

Sa variante la plus répandue est la classe par cas d'usage : BookAccommodationHandler, avec un constructeur qui reçoit les dépendances et une méthode handle() ou execute(). C'est une convention importée d'écosystèmes où la fonction n'est pas un objet de première classe — où il fallait une classe pour transporter une fonction.

En JavaScript, ce n'est pas le cas, et le chapitre 10 en avait déjà tiré parti :

les fonctions y sont des citoyennes de première classe : on peut les passer en paramètre, les stocker dans une variable, et — c'est ce qui nous intéresse ici — les retourner.

book(payload) retourne une fonction qui attend ses dépendances. C'est un BookAccommodationHandler, sans classe, sans conteneur, sans annotation.

Un port n'a pas besoin d'être une classe

Deuxième idée reçue, et elle coûte cher en cérémonie :

class Mailer {
  sendMail(to: string, content: string) { /* ... */ }
}
 
function makeApp(mailer: Mailer) {
  mailer.sendMail("bill@example.com", "Hello Bill");
}
 
makeApp(new Mailer());                                        // évidemment
makeApp({ sendMail: (to, content) => console.log(to) });      // ceci marche aussi

La seconde ligne compile. TypeScript utilise un typage structurel : ce qui compte est la forme, pas la filiation. Aucun implements, aucun héritage, aucun instanceof.

C'est ce qui nous a permis, depuis le chapitre 17, d'écrire des dépendances comme celle-ci :

const testDateProvider = {
  today: () => CalendarDay.parse("2023-06-12").value,
};

Trois lignes, aucune classe, et elle satisfait parfaitement l'interface DateProvider.

Conséquence pratique : pour un port sans état, préférez l'objet littéral. Vous n'aurez ni new, ni constructeur vide, ni classe abstraite à hériter.

Et gardez la classe pour les ports qui, eux, ont un état à tenir — une connexion, un tableau, un compteur. C'est exactement le partage entre systemDateProvider (objet) et SQLBookingRepository (classe).

Ce que chaque forme donne vraiment

Écartons quelques arguments faux, dans les deux sens.

"Les classes sont plus testables." Non. Ce qui rend testable, c'est l'injection des dépendances (chapitre 12) et la pureté (chapitre 21). Nos fonctions canHost et Stay.overlaps sont ce qu'il y a de plus facile à tester dans tout le projet.

"Les fonctions sont plus fonctionnelles." Une fonction de 200 lignes qui mute une variable globale n'a rien de fonctionnel. Ce qui compte est l'absence d'effet de bord, pas la syntaxe.

"Les classes, c'est de la POO, donc c'est lourd." CalendarDay fait trente lignes et protège un invariant. On peut difficilement faire plus léger.

Voici ce que chaque forme apporte réellement.

La classe donne des champs privés (#iso) — une vraie privauté, vérifiée à l'exécution, pas une convention de nommage. Elle donne un constructeur, qui est le point d'injection naturel. Elle donne instanceof, dont CalendarDay.parse se sert au chapitre 21. Et elle partage ses méthodes via le prototype : dix mille CalendarDay en mémoire, un seul exemplaire de isBefore.

La fonction, et plus précisément la fermeture, donne aussi une vraie privauté — une variable capturée est inaccessible de l'extérieur. Elle donne l'application partielle, dont notre currying est un cas. Et surtout, elle n'a pas de this.

Le piège du this

Ce dernier point n'est pas théorique. C'est le bug JavaScript le plus banal, et il ne touche que les classes.

const repository = new MemoryBookingRepository();
const save = repository.save;
await save(booking);
// TypeError: Cannot read properties of undefined (reading '_bookings')

Extraite de son objet, la méthode perd son this. Et l'extraction est rarement aussi visible : bookings.map(repository.save), setTimeout(this.refresh, 100), un { save: repository.save } dans un objet de dépendances bricolé.

Trois parades : repository.save.bind(repository), une lambda (b) => repository.save(b), ou un champ initialisé avec une fonction fléchée (save = async (booking) => { ... }), qui capture this définitivement.

La fermeture, elle, ne connaît pas ce problème :

export function makeMemoryBookingRepository() {
  const bookings = [];
  return {
    save: async (booking) => { /* utilise `bookings` directement */ },
    findById: async (id) => bookings.find((b) => b.id === id) ?? null,
  };
}

Aucun this, une privauté réelle, et un objet dont on peut extraire les méthodes sans précaution. C'est une alternative parfaitement légitime à nos classes de repository — plus coûteuse en mémoire si vous en créez des milliers, ce qui n'est le cas d'aucun repository.

Le tableau de décision

vous écrivez…forme
une règle pure (canHost)fonction
une valeur avec invariant (Stay)classe (ou fermeture)
une entité avec identité et transitions (Booking)classe
un cas d'usage (book)fonction qui retourne une fonction
un port sans état (dateProvider)objet littéral
un port avec état (SQLBookingRepository)classe ou fabrique
une fabrique d'erreur ou d'événementfonction
un objet appelé une seule fois après constructionfonction

La vraie règle

Le chapitre 3 avait raison sur l'essentiel, et il faut y revenir pour conclure.

c'est quand même préférable de conserver de la cohérence sur la façon de faire.

Le coût d'un projet ne vient pas du choix entre classe et fonction. Il vient de l'incohérence : trois façons de déclarer une dépendance, deux façons de construire une valeur, un repository sur deux en fabrique.

Et le chapitre 14 avait donné le garde-fou :

Ce n'est pas un concours de celui qui connaît le + de design patterns orientés objet. Ni de celui qui veut faire étalage de sa maîtrise de la monade reader.

Si votre équipe préfère tout écrire en fermetures, faites-le — le critère "état plus comportement" s'applique à l'identique, makeStay remplace new Stay, et rien de ce cours ne s'effondre. Si elle préfère des classes partout, y compris pour canHost, ce sera un peu plus verbeux et parfaitement viable.

Ce qui compte est qu'un développeur qui ouvre un fichier reconnaisse la façon de faire des autres fichiers.

Il nous reste à vérifier que tout cet édifice tient — et à le mettre en ligne.


Le code de cette étape est disponible ici (opens in a new tab).