Refactoring JavaScript
Chapitre 7 / 10 · 13 min de lecture

Refactorer au sein d'une hiérarchie

Héritage et hiérarchies de classes : quand factoriser vers le haut, quand descendre, et surtout quand préférer la composition.

Une hiérarchie de classes, c'est un arbre de relations « est-un » : un mot anglais est un mot, un chien est un animal. L'héritage (inheritance) promet deux bénéfices très concrets : réutiliser du code partagé pour en répéter moins, et remplacer des conditions par du polymorphisme. Mais c'est aussi l'un des terrains où une bonne intention tourne au cauchemar le plus vite. Une classe de base trop chargée devient fragile ; une hiérarchie trop profonde devient illisible ; un parent et ses enfants finissent par n'avoir plus rien en commun.

Ce chapitre suit Evan Burchard dans un double mouvement : construire une hiérarchie proprement (remonter le code commun vers le parent, descendre le spécifique vers les enfants), puis la démonter quand elle nuit. Le fil rouge reste celui du livre : chaque transformation se fait par petits pas, sous la protection de tests, et le grand message de fond est de préférer souvent la composition à l'héritage (composition over inheritance). Comme toujours en JavaScript, ce que l'on appelle « classe » cache une mécanique de prototypes qu'il vaut mieux comprendre avant de s'y fier.

Construire la hiérarchie : remonter le code commun

Partons d'un cas réel : une liste de vocabulaire où chaque mot sait compter ses caractères. Deux classes naissent côte à côte, et la duplication saute aux yeux.

// ❌ Avant : deux classes strictement identiques.
class EnglishWord {
  constructor(word) {
    this.word = word;
  }
  count() {
    return this.word.length;
  }
}

class JapaneseWord {
  constructor(word) {
    this.word = word;
  }
  count() {
    return this.word.length;
  }
}

Le code smell est flagrant : constructeur et méthode count sont copiés à l'identique. La parade classique consiste à extraire une superclasse (extract superclass) puis à remonter (pull up) les membres communs vers elle. Les sous-classes ne conservent que le lien d'héritage.

// ✅ Après : le commun est remonté dans Word.
class Word {
  constructor(word) {
    this.word = word;
  }
  count() {
    return this.word.length;
  }
}

class EnglishWord extends Word {}
class JapaneseWord extends Word {}

const japaneseWord = new JapaneseWord("犬");
const englishWord = new EnglishWord("dog");

Le code est bien plus court, et EnglishWord comme JapaneseWord héritent maintenant de Word. Burchard le note : si les sous-classes étaient réellement identiques, on pourrait les supprimer entièrement — effondrer la hiérarchie (collapse hierarchy) — et n'utiliser que new Word("…"). On les garde uniquement parce qu'on s'apprête à leur donner des comportements distincts.

Astuce

Avant de remonter quoi que ce soit, écrivez des tests d'interface : japaneseWord.count() === 1, englishWord.word === "dog". Ce sont eux que vous voudrez préserver à travers chaque variante de la hiérarchie. Les détails internes (prototype, constructor) changeront ; l'interface, non.

Descendre le spécifique vers les sous-classes

Les deux types de mots ne se cherchent pas dans le même dictionnaire. Une première tentative consiste à mettre la logique dans le parent, derrière un instanceof :

// ❌ Avant : le parent interroge le type de l'enfant.
class Word {
  lookUp() {
    if (this instanceof JapaneseWord) {
      return `http://jisho.org/search/${this.word}`;
    } else {
      return `https://en.wiktionary.org/wiki/${this.word}`;
    }
  }
}

L'opérateur instanceof lie la condition au nom d'une classe précise : c'est un couplage rigide. On peut déjà l'assouplir en stockant un attribut language sur chaque mot et en testant cette valeur plutôt que le type. Mais Burchard va plus loin : un if qui demande « qui suis-je ? » est précisément ce que le polymorphisme sait éliminer. On supprime lookUp du parent et on le descend (push down) dans chaque enfant, dans sa forme spécifique.

// ✅ Après : chaque sous-classe porte sa propre version.
class EnglishWord extends Word {
  lookUp() {
    return `https://en.wiktionary.org/wiki/${this.word}`;
  }
}

class JapaneseWord extends Word {
  lookUp() {
    return `http://jisho.org/search/${this.word}`;
  }
}

On obtient deux fonctions de trois lignes au lieu d'une de cinq : on a « gagné » une ligne, mais chaque fonction est plus simple à tester et n'a qu'un seul chemin d'exécution. C'est subtil mais décisif : si l'un des cas se complexifie un jour — par exemple changer de dictionnaire selon le type de mot dans une même langue — la complexité grossira dans un contexte réduit. Les if appellent les if ; les éviter maintient le volume de code bas, et le sous-classement aide à les supprimer.

Note

Le principe sous-jacent s'appelle « dire, ne pas demander » (tell, don't ask) : on simplifie les fonctions pour qu'elles cessent de poser des questions du type « que suis-je ? ». Le sous-classement n'est qu'une façon d'y arriver. Parfois une simple condition reste préférable ; d'autres fois on évitera et la condition et le sous-classement (voir le patron Strategy).

super et les pièges du constructeur

Donner à chaque sous-classe son attribut language révèle une bizarrerie de JavaScript. Si l'on écrit un constructeur dans EnglishWord sans appeler super, on obtient une erreur : this est undefined. Pour sous-classer, il faut payer tribut au parent en appelant super() — même si la superclasse n'a aucun constructeur à exécuter.

// ✅ Le constructeur descendu DOIT appeler super.
class EnglishWord extends Word {
  constructor(word) {
    super();
    this.word = word;
    this.language = "English";
  }
}

Burchard recommande un principe d'équilibre : faire en sorte que la superclasse en fasse le plus possible, ou le moins possible, mais pas un mélange flou des deux. Soit les enfants délèguent tout au parent via super(word, "English"), soit ils assument tout eux-mêmes. Mélanger les deux, multiplier les variations entre constructeurs frères, ou empiler les super sur plusieurs niveaux rend tout plus confus.

Quatre façons de bâtir la même hiérarchie

Le mot-clé class n'est que du sucre syntaxique. Burchard reconstruit la même hiérarchie de quatre manières, pour montrer ce qui se cache dessous.

ApprocheCommentLien de prototypeVerdict de Burchard
Classesclass … extendsÉtabli automatiquementStandard recommandé
Fonctions constructeursnew, Object.create manuelÀ recâbler à la mainLa pire option
Objets littérauxObject.create + Object.assignDélégation directeSimple pour les cas simples
Fonctions fabriquesune fonction qui retourne un objetRompu par défautPlus de contrôle

La version fonction constructeur illustre toute la plomberie que class masque. Il ne suffit pas d'appeler le parent avec Word.call(this, …) : pour que la chaîne de prototypes (prototype chain) soit correcte, il faut recâbler deux lignes par sous-classe — EnglishWord exige le même recâblage, non montré ici par souci de concision.

function Word(word, language, lookUpUrl) {
  this.word = word;
  this.language = language;
  this.lookUpUrl = lookUpUrl;
  this.count = function () {
    return this.word.length;
  };
  this.lookUp = function () {
    return this.lookUpUrl + this.word;
  };
}

function JapaneseWord(word) {
  Word.call(this, word, "Japanese", "http://jisho.org/search/");
}

// Sans ces deux lignes, la chaîne de prototypes est cassée.
JapaneseWord.prototype = Object.create(Word.prototype);
JapaneseWord.prototype.constructor = JapaneseWord;

Pourquoi ces lignes comptent ? Sans elles, les tests d'interface passent quand même : japaneseWord.count() fonctionne, car les propriétés sont copiées directement sur l'instance. Mais si l'on ajoute plus tard Word.prototype.reportLanguage = …, l'instance ne le verra jamais : elle ignore son ascendance. C'est tout l'intérêt — et tout le danger — des prototypes.

Attention

Les fonctions constructeurs sont, pour Burchard, la pire option : ce sont d'éternelles tentatives de simuler des classes, et le web fourmille de variantes contradictoires vieilles de plusieurs années. Si vous hésitez, choisissez class. Et gardez vos tests au niveau de l'interface : ne vous noyez pas dans le « ventre sombre et profond » de JavaScript (__proto__, prototype, constructor, instanceof…).

Objets littéraux et fonctions fabriques

Sans new, on peut utiliser un objet comme prototype délégué (delegate prototype) et le cloner via Object.create, puis le compléter avec Object.assign. La fonction fabrique (factory function) emballe cette mécanique :

const word = {
  count() {
    return this.word.length;
  },
  lookUp() {
    return this.lookUpUrl + this.word;
  },
};

const japaneseWordFactory = (theWord) =>
  Object.assign(Object.create(word), {
    word: theWord,
    language: "Japanese",
    lookUpUrl: "http://jisho.org/search/",
  });

const japaneseWord = japaneseWordFactory("犬");

On note l'emploi de Object.create(word) comme cible d'Object.assign : passer word directement écraserait l'original au lieu d'en produire une copie fraîche. Les fabriques atteignent les deux objectifs du sous-classement — pas de répétition, pas de if — tout en travaillant avec des objets plutôt qu'avec new.

La contrepartie : en coupant le lien au prototype, on perd la capacité d'enrichir d'un coup tous les objets existants. Avec une classe, Word.prototype.reportLanguage = … se répercute sur toutes les instances déjà créées. Avec des fabriques, chaque objet est isolé. C'est souvent un avantage (moins d'effets de bord à distance), parfois une limite à connaître.

Pourquoi tant de méfiance envers l'héritage ?

Burchard prend au sérieux les critiques de l'OOP classique, car elles visent de vrais problèmes :

  • Le couplage parent-enfant est fort par nature. Un changement dans la classe de base se propage en silence vers tous les descendants : c'est le problème de la classe de base fragile (fragile base class).
  • extends encourage les hiérarchies profondesclass Dog extends Pet extends Animal extends Organism. Il ne les impose pas, mais il y invite. Une hiérarchie profondément imbriquée est difficile à déboguer et à suivre.
  • L'héritage multiple n'existe pas en JavaScript. Écrire class Dog extends (Animal, Barky, Bitey) ne fait pas ce qu'on croit : l'expression entre parenthèses s'évalue à sa dernière valeur, exactement comme (1, 4, 3, 7) vaut 7. Dog n'hérite donc que de Bitey.

Pour combiner plusieurs comportements, on utilise un mixin via Object.assign, et l'on découvre encore des subtilités : dans Object.assign(cible, a, b), les propriétés de droite l'emportent, et la cible (Object.create(animal) ou {}) détermine s'il subsiste ou non un lien de prototype vers animal.

// Mixin : bite/bark/beFluffy combinés sur un seul objet.
const myPet = Object.assign(Object.create(animal), barky, bitey);
// bitey.bark l'emporte sur barky.bark car bitey est passé en dernier.

Préférer la composition : la relation « a-un »

Jusqu'ici tout reposait sur « est-un ». Mais composer des objets n'implique pas d'hériter de leurs propriétés. C'est le cœur du message : préférer la composition à l'héritage, en remplaçant l'héritage par la délégation (delegation) à des collaborateurs.

L'exemple parlant de Burchard : un HorseWithABriefCase est-il un sous-type de Horse ? De HorseWithObject ? De BusinessHorse ? Vouloir tout exprimer par héritage mène à une explosion de classes absurdes — au point qu'une SickHorse ne pourrait pas recevoir ses antibiotiques parce que DoctorHorseWithoutAnyMedicine est inutile dans ce scénario. La solution est de stocker des propriétés sur l'objet plutôt que de multiplier les types.

// ✅ Composition : le cheval A-UN inventaire, pas N sous-types.
const horse = {
  inventory: ["briefcase"],
  profession: "hippo jockey",
  healthy: true,
};

Si inventory se complexifie, on l'extrait en objet à part — comme une songList que l'on peut faire grandir indépendamment — et l'objet parent y délègue au lieu d'en hériter. On retrouve la distinction « est-un » (is-a) contre « a-un » (has-a) : un Person qui serait aussi un DatabaseRecord n'est pas « juste » un organisme ; mieux vaut lui attacher les comportements pertinents que l'enfermer dans une hiérarchie rigide.

À retenir

Burchard nuance : dans la vraie vie, les pires bases de code JavaScript souffrent bien plus souvent d'un manque de structure que d'un excès de mauvaise structure. L'héritage abusif existe et progressera avec la mode des classes, mais ne devenez pas paranoïaque : ni les objets, ni les classes ne sont mauvais en soi. Le problème, c'est l'héritage mal appliqué.

Démonter une hiérarchie qui nuit

Deux antipatrons reviennent souvent. Dans les deux cas, la motivation part d'une bonne intention — éviter le copier-coller — mais aboutit à de la structure au mauvais endroit.

Hyperextension : la hiérarchie trop profonde

SpecificClientReport hérite de ClientReport, qui hérite de GenericReport, qui hérite de Report — quatre niveaux où chaque maillon ne fait que réempiler des params via super.

// ❌ Avant : 4 niveaux qui se relaient des params.
class SpecificClientReport extends ClientReport {
  constructor(params) {
    super(params);
    this.params = params;
  }
  printReport(params) {
    return super.printReport(Object.assign(this.params, params));
  }
}

La méthode sûre commence par un test de caractérisation (characterization test), qui capture le comportement actuel : on lance report.printReport({extra: "params"}), on observe ce qu'il renvoie, et on fige cette sortie dans une assertion. Tests au vert, on peut élaguer (prune) la hiérarchie par petits pas. On remonte le point de création vers les classes parentes — new GenericReport(...) au lieu de new SpecificClientReport(...) — et tant que le test passe, on supprime les feuilles devenues inutiles.

// ✅ Après : la feuille indépendante, sans parent.
class GenericReport {
  constructor(params) {
    this.params = params;
  }
  printReport(params) {
    return Object.assign(this.params, params);
  }
}

Une fois extends Report retiré, on constate qu'on peut aussi inliner le parent : super.printReport ne faisait que renvoyer ses arguments, donc l'appel à super était superflu. Restent les noms trop vagues — params, GenericReport — qu'on renommera ensuite.

Piège courant

N'essayez jamais de « refactorer » d'un seul geste une hiérarchie complexe truffée d'effets de bord, d'attributs et de fonctions — surtout sans tests. L'objectif est de découper des feuilles indépendantes et d'extraire les fonctions partagées, un pas à la fois. Le contrôle de version et des tests couvrant tous les consommateurs de la hiérarchie sont indispensables.

« La chèvre et le chou élevés par un loup »

Ici, les enfants ne ressemblent ni au parent ni l'un à l'autre. Le parent Agent ne sert qu'à ajouter une couche d'indirection — pire, son constructeur décide au pile ou face s'il fabrique un User ou un Project. Le couplage est arbitraire et force du type-checking partout.

// ❌ Avant : un parent qui choisit son enfant au hasard.
class Agent {
  constructor(name, type) {
    this.name = "name";
    if (Math.random() > 0.5) {
      this.type = "user";
    } else {
      this.type = "project";
    }
  }
}
class User extends Agent {
  sayName() {
    return `my name is ${this.name}`;
  }
}

Sous protection d'un test de caractérisation, Burchard procède par étapes minuscules : extraire le coinToss dans une fonction, le remonter hors du constructeur vers le code appelant, puis descendre (push down) toute la logique du constructeur dans User et Project. Reste l'irritant super() obligatoire tant qu'on hérite encore d'Agent. Une fois User et Project autonomes, on supprime carrément le parent.

// ✅ Après : deux classes indépendantes, plus d'extends ni de super.
class User {
  constructor(name, type) {
    this.name = name;
    this.type = type;
  }
  sayName() {
    return `my name is ${this.name}`;
  }
}
class Project {
  constructor(name, type) {
    this.name = name;
    this.type = type;
  }
  sayTheName() {
    return `the project name is ${this.name}`;
  }
}

let agent;
if (coinToss()) {
  agent = new User("name", "user");
} else {
  agent = new Project("name", "project");
}

Plus de super, plus de extends. La leçon de Burchard est nette : quand deux classes ne partagent qu'un peu d'état ou de comportement, le sous-classement est un mauvais moyen d'éliminer la duplication — il entraîne l'équipe vers du type-checking sans fin. Parfois un simple attribut type suffit ; d'autres fois on extrait la duplication dans une fonction ou un objet collaborateur. Dans tous les cas, refactorer de la duplication est facile et sûr, alors que démanteler une hiérarchie « chèvre et chou » ne l'est jamais.

À retenir

  • Remonter (pull up) le commun, descendre (push down) le spécifique : extrayez une superclasse pour éliminer la duplication, et placez dans chaque enfant ce qui n'appartient qu'à lui.
  • Le polymorphisme remplace les if : descendre une méthode dans les sous-classes donne des fonctions à un seul chemin, plus simples à tester. Pensez « dire, ne pas demander ».
  • class n'est que du sucre : sous le capot, une chaîne de prototypes qu'il faut recâbler à la main avec les fonctions constructeurs. Testez l'interface, pas le « ventre sombre » de JavaScript.
  • Méfiez-vous de l'héritage : couplage parent-enfant fort, classe de base fragile, hiérarchies profondes illisibles, pas d'héritage multiple. extends invite à la profondeur sans l'imposer.
  • Préférez la composition à l'héritage : modélisez « a-un » par délégation à des collaborateurs plutôt que « est-un » par sous-types. Stockez des propriétés au lieu de multiplier les classes.
  • Démontez par petits pas, sous tests : capturez le comportement par des tests de caractérisation, élaguez les feuilles une par une, et rappelez-vous que défaire une mauvaise hiérarchie est bien plus risqué que factoriser une simple duplication.