Malgré nos efforts pour tester et valider notre code, on finit quand même avec des bogues en production qui sont causés par des états invalides dans notre système. Mieux tester n’est pas notre unique outil pour éviter que ça arrive. Une autre façon de rendre notre code plus solide est de tout simplement empêcher que ces états puissent exister.
Le principe du «Make Impossible States Impossible» est un concept simple et puissant qui permet de concevoir du code plus robuste et résilient. Ce principe prône la prévention des erreurs en ne permettant pas au code d’entrer dans un état invalide ou inconscient. Une façon d’appliquer ce principe est d’utiliser des types contraignants qui encodent nos règles d’affaires. Un type est un test gratuit. On cherche à augmenter la capacité du compilateur à nous aider!
Appliquer ce principe permet de rendre nos API plus claires en ne laissant pas place à la confusion. Il nous permet de travailler avec des types valides en tout temps et donc, pas de cas d’exception à gérer. Les développeurs qui utilisent notre code peuvent l’utiliser sans avoir à comprendre l’implémentation. Tout ça nous fait gagner du temps, nous permet d’écrire moins de tests et, surtout, rend notre code plus robuste.
Dans cet article, nous allons explorer à travers trois exemples comment repenser notre code pour suivre le principe du «Make Impossible States Impossible» et gagner en robustesse.
Quand j’utilise une API, je ne veux pas avoir à comprendre comment elle fonctionne pour l’utiliser correctement. Je ne devrais pas être en mesure de créer des états invalides, peu importe ma connaissance du code.
Prenons l’exemple d’un component UI dont le but est d’afficher un bloc de texte. On veut permettre à l’utilisateur de choisir comment on gère l’overflow du texte. Il a le choix entre :
Une version de l’interface du composant que j’ai rencontré dans mon travail ressemblait à :
type Props = {
overflow?: "ellipsis" | "expandable",
numberOfLines? : number,
maxHeight?: number,
}
Le problème avec ce genre d’interface est qu’on tente de mixer plusieurs possibilités ensemble et que, pour y arriver, on met tout optionnel. Alors qu’en réalité, les paramètres ne sont pas vraiment facultatifs. L’utilisateur doit donc connaître l’implémentation pour savoir que s’il veut un overflow ellipsis, il doit passer le nombre de lignes.
Vu que tous les paramètres sont facultatifs, on a des cas limites à gérer et donc, à tester.
Voici une autre façon de définir l’interface de notre component :
type Props = {
overflow: Ellipsis | Expandable | FullText
}
type Ellipsis = {
type : 'ellipsis',
numberOfLines: number
}
type Expandable = {
type : 'expandable',
maxHeight: number
}
type FullText = {
type : 'fullText'
}
En prenant l’ensemble de nos possibilités (Ellipsis ou Expandable ou FullText), on donne un choix au développeur. Il n’a pas besoin de savoir qu’il doit passer un nombre de lignes avec le mode ellipsis. Notre interface l’oblige à passer les bons paramètres en fonction du mode choisi.
Il n’y a pas de confusion ni d’erreurs possibles. Nous n’avons pas à gérer le cas où l’utilisateur ne passe pas une des propriétés. Donc, on n’a pas à se demander comment gérer ce cas et en plus, on a beaucoup moins de tests nécessaires.
Supposons un système qui veut contrôler l’ouverture et la fermeture d’appareils de contrôle climatique. Commençons avec une version simplifiée de deux appareils. Un appareil pour chauffer et un pour refroidir.
La contrainte : on ne doit pas chauffer en même temps que l’on refroidit.
Voici une représentation possible de notre modèle :
type Control = { heating: boolean, cooling: boolean}
Un état invalide pourtant permis par le type est
const control = { heating: true, cooling: true }
Avec ce genre de design, on devra avoir un bout de code quelque part qui vérifie que la condition est respectée. Il est facile de faire une erreur, d’oublier que la règle existe et qu’une incohérence survienne. On compte sur le fait que les développeurs soient au courant de la règle et y pensent.
Notre situation comporte deux états qui sont interdépendants. On pourrait penser à les unir puisque l’un ne peut exister en même temps que l’autre.
Voici une représentation possible :
type Control = "heating" | "cooling" | "standBy"
L’avantage est qu’il est maintenant impossible de chauffer en même temps de climatiser. Pas besoin de test ou de vérification dans le code. C’est encodé dans le type!
Aussi, je propose l’utilisation du terme standBy pour le cas où nous ne sommes ni en train de chauffer, ni en train de refroidir. Je préfère ne pas utiliser le undefined ou null qui documente mal notre état.
Dans la même thématique, supposons que l’on veut représenter un appareil de chauffage comme une fournaise. La fournaise prend deux à cinq minutes pour démarrer.
La contrainte : on ne doit pas éteindre une fournaise qui est en cours de démarrage.
Voici une implémentation naïve du modèle de fournaise :
class NaiveFurnace {
private isOn: boolean;
private isStarting: boolean;
public constructor(){
this.isOn = false;
this.isStarting = false;
}
public turnOn(){
this.isOn = true;
this.isStarting = true;
}
public turnOff(){
if (this.isStarting){
// throw an exception? return ?
}
this.isOn = false;
}
}
Avec cette implémentation, on a plusieurs états et actions impossibles qui sont quand même permis par le type :
Le problème est que l’utilisateur de la classe doit connaître la contrainte pour l’utiliser correctement. Il doit aussi savoir qu’une exception sera lancée dans une telle situation et la gérer. Tout ça parce qu’on permet à des états invalides d’exister. Finalement, on doit s’assurer d’avoir une bonne couverture de tests.
Voici une solution que je vous propose. Il en existe sûrement plusieurs, l’important est de garder en tête le principe du «Make Impossible States Impossible».
D’abord, je liste les états possibles de la fournaise. L’énumération ne sera pas exposée à l’extérieur. Ainsi, je ne permets pas au client de mon interface de passer d’un état à l’autre à sa guise.
enum FurnaceStage {
Heating = "heating",
Starting = "starting",
StandBy = "standBy"
}
Ensuite, je crée des types qui représentent les états de la fournaise et les actions possibles selon chacun :
export type StartingFurnace = { state: FurnaceStage.Starting }
export type HeatingFurnace = {
state: FurnaceStage.Heating,
turnOff: () => StandByFurnace
}
export type StandByFurnace = {
state: FurnaceStage.StandBy,
turnOn: () => StartingFurnace
}
export type Furnace = StartingFurnace | HeatingFurnace | StandByFurnace
Je peux finalement me créer des constructeurs de types pour chacun (un peu comme des classes) pour m’aider :
export const StandByFurncace = () : StandByFurnace => ({
state: FurnaceStage.StandBy,
turnOn : () => StartingFurnace()
});
const HeatingFurnace = () : HeatingFurnace => ({
state: FurnaceStage.Heating,
turnOff: () => StandByFurncace()
});
const StartingFurnace = (): StartingFurnace => ({
state: FurnaceStage.Starting,
});
Grâce aux nouveaux types que j’ai introduits, je limite les actions possibles selon l’état de ma fournaise et l’utilisateur n’a d’autre choix que de suivre les transitions valides selon l’état actuel. Il ne peut pas oublier de vérifier que la fournaise peut être fermée, le pattern matching lui oblige.
C’est aussi l’utilisateur de mon module qui est responsable de choisir comment gérer les cas limites. Aucune exception n’est lancée dans mon module et toutes mes fonctions sont déterministes et pures. Aucun test n’est requis, tout est inclus directement dans mon type.
Le but de cet exercice était de nous amener à réfléchir à notre modélisation pour la rendre plus proche de notre domaine. Comme pour tout en programmation, rien n’est absolu et tout devrait être un compromis. Je ne crois pas que l’on devrait encoder tout dans notre type. Le code doit rester simple et compréhensible. Par contre, prendre conscience de ce principe m’a permis de poser un jugement critique sur le code que j’écris et que je lis.
«Je vous encourage à essayer de vous poser aussi ces questions : est-ce que mon type permet de créer des états invalides? Est-ce que ça vaut la peine que j’empêche ces états d’exister? Est-ce que quelqu’un peut utiliser mon code sans avoir à aller comprendre l’implémentation?»
Souvent, on peut trouver des solutions plus élégantes et plus faciles à raisonner en réfléchissant autrement.
Pour approfondir le sujet :