Accueil / Articles

Les enums et interfaces de TypeScript en tant que briques de base pour la conception de bas niveau

Comment les enums de TypeScript éliminent les erreurs liées aux chaînes magiques, quand choisir un type ou une interface, et comment les interfaces, les implémentations et le typage structurel définissent des contrats dans la conception de bas niveau.

Éditeur : REACTAPP.TOP7 min de lecture1599 mots

Sources: Day 1: (Enums and Interfaces) LLD With TS | Part 2

Après les classes et les objets, les outils suivants pour la conception de bas niveau (LLD) en TypeScript sont les enums et les interfaces. Ils semblent simples, mais ils permettent de maintenir une cohérence dans la conception : les enums définissent les valeurs autorisées, tandis que les interfaces définissent les contrats des objets. Cet article montre les erreurs qu’ils permettent d’éviter, comment choisir entre type et interface, ainsi que ce que signifie le typage structuré pour implements.

Le problème résolu par les enums : des chaînes de caractères aléatoires

Supposons qu’une commande dans votre application puisse être dans l’un de trois états :

PENDING
SUCCESS
REJECTED

Le code d’un module vérifie l’état d’une commande en attente de la manière suivante :

if (order.status === "PENDING") {
  // ...
}

Un autre module vérifie l’état de succès :

if (order.status === "SUCCESS") {
  // ...
}

Tout fonctionne jusqu’à ce qu’un nouveau collègue écrit une vérification presque identique mais utilisant des minuscules :

if (order.status === "pending") {
  // ...
}

La comparaison de chaînes est sensible à la casse, donc ce sont des valeurs différentes :

"PENDING" !== "pending"

La condition n’est jamais vraie en silence. La cause racine réside dans l’utilisation de valeurs saisies manuellement partout : qui se souvient si c’était PENDING, pending ou Pending ?

Définir les valeurs autorisées une seule fois

Une enum déclare un ensemble fixe de valeurs nommées en un seul endroit, permettant ainsi au reste du code de les référencer par leur nom. Ici, chaque membre reçoit explicitement sa valeur de chaîne :

enum OrderStatus {
  PENDING = "PENDING",
  SUCCESS = "SUCCESS",
  REJECTED = "REJECTED"
}

Plutôt que de comparer avec une chaîne brute, comme dans cette vérification,

if (order.status === "PENDING") {
  // ...
}

on compare avec le membre de l’enum :

if (order.status === OrderStatus.PENDING) {
  // ...
}

Cela s’applique également à toutes les autres vérifications de statut :

if (order.status === OrderStatus.SUCCESS) {
  // ...
}

Personne n’a besoin de se souvenir de l’orthographe exacte, seulement du nom du membre, que l’éditeur complète automatiquement :

OrderStatus.PENDING

Une faute d’orthographe dans le nom d’un membre est désormais une erreur de compilation plutôt qu’une incohérence silencieuse en temps d’exécution.

Pourquoi c’est important à mesure que la base de code grandit

Imaginez que cet état apparaît en vingt endroits. Sans enum, la cohérence dépend de la discipline des développeurs, et les variations apparaissent progressivement :

"PENDING"
"PENDING"
"pending"
"Pending"
"PENDING"

Avec un enum, chaque emplacement fait référence à la même déclaration :

OrderStatus.PENDING

En bref, un enum est un ensemble de constantes nommées et prédéfinies.

Les enums de chaînes de caractères sont plus faciles à déboguer que ceux de nombres, car leurs valeurs restent lisible dans les journaux d’erreur. Une union de littéraux de chaîne, présentée ci-dessous, offre une sécurité similaire sans code en temps d’exécution, ce que préfèrent certains équipes.

type versus interface

Pour les entretiens et les examens, un modèle mental simple des deux suffit.

type : composition et modélisation des types

  • Flexible pour combiner des types.
  • Gère les unions, les intersections, les tuples, les primitives, les types mappés et bien plus encore.
  • Idéal lorsque vous décrivez ou composez principalement des données.
  • Une union de chaînes de caractères littérales en est un exemple typique :

    type Status = "PENDING" | "SUCCESS" | "FAILED";
    

    De même pour un identifiant qui peut être soit une chaîne de caractères, soit un nombre :

    type ID = string | number;
    

    interface : contrats pour les objets et les classes

    • Décrit principalement la structure des objets et les contrats que les classes doivent respecter.
    • Puede être mise en œuvre par une classe à l’aide de implements.
    • Idéal lorsque plusieurs implémentations doivent respecter le même contrat.
    • Prend en charge la fusion des déclarations, de sorte que deux déclarations portant le même nom se combinent en une seule.
    interface Payment {
      amount: number;
      pay(): void;
    }
    

    Règle générale

    Lorsque vous avez besoin de composer ou de modéliser un type, optez pour type :

    Need to compose/model a type?
            ↓
          type
    

    Lorsque vous avez besoin d’un contrat entre des classes ou des objets, utilisez interface:

    Need a contract between classes/objects?
            ↓
        interface
    

    C’est une directive, pas une loi : les deux peuvent décrire la structure des objets.

    Les interfaces décrivent la structure des objets

    Le moyen le plus simple d’aborder les interfaces est de réfléchir à la structure d’un objet. Voici un objet JavaScript simple :

    const student = {
      id: 101,
      name: "Rahul",
      age: 20
    };
    

    TypeScript vous permet de décrire cette structure, par exemple avec un alias de type :

    type Student = {
      id: number;
      name: string;
      age: number;
    };
    

    Plusieurs objets peuvent alors être vérifiés selon cette même structure :

    const student1: Student = {
      id: 101,
      name: "Rahul",
      age: 20
    };
    
    const student2: Student = {
      id: 102,
      name: "Aman",
      age: 21
    };
    

    Au fur et à mesure que l’application se développe, nommer cette structure comme un contrat réutilisable est précisément l’objectif d’une interface.

    Une interface en tant que contrat

    Même structure exprimée sous forme d’interface :

    interface Student {
      id: number;
      name: string;
      age: number;
    }
    

    Toute valeur qui doit être traitée comme un Student doit désormais la respecter :

    const student: Student = {
      id: 101,
      name: "Rahul",
      age: 20
    };
    

    Omettre une propriété obligatoire, comme ici,

    const student: Student = {
      id: 101,
      name: "Rahul"
    };
    

    et le compilateur signale une erreur car cette propriété manque :

    age
    

    En réalité, l’interface stipule : pour être traité comme ce type, vous devez respecter ce contrat.

    Les interfaces définissent quoi, pas comment

    Cette distinction est fondamentale dans LLD. Une interface indique à quoi quelque chose doit ressembler ou quelles fonctionnalités il doit fournir, mais pas comment il le fait. Prenons un contrat de paiement :

    interface Payment {
      amount: number;
      pay(): void;
    }
    

    Il garantit exactement deux choses :

    Payment must have:
    → amount
    → pay()
    

    Il ne dit rien sur le fonctionnement de pay(). Une classe pourrait traiter une carte, en partant de la propriété obligatoire amount,

    class CreditCardPayment implements Payment {
      amount: number;
    

    et fournir sa propre version de pay() :

      pay() {
        console.log("Paying using credit card");
      }
    }
    

    Une autre pourrait effectuer le paiement via UPI, le système de paiements instantanés de l’Inde, en utilisant la même propriété.

    class UPIPayment implements Payment {
      amount: number;
    

    et une version différente de pay():

      pay() {
        console.log("Paying using UPI");
      }
    }
    

    Même contrat, différentes implémentations, ce qui permet aux appelsants d’utiliser n’importe quel mode de paiement :

    Interface
       ↓
    Defines the contract
       ↓
    Multiple implementations
       ↓
    Different implementation details
    

    Le mot-clé implements

    implements rend la relation explicite. Compte tenu du contrat,

    interface Payment {
      amount: number;
      pay(): void;
    }
    

    une classe déclare qu’elle le respecte :

    class CreditCardPayment implements Payment {
      amount: number;  pay() {
        console.log("Paying with credit card");
      }
    }
    

    La clause

    implements Payment
    

    demande au compilateur de vérifier que la classe satisfait le contrat Payment. Si ce n’est pas le cas, par exemple parce que pay() est manquant,

    class CreditCardPayment implements Payment {
      amount: number;
    }
    

    le compilateur signale l’élément absent

    pay()
    

    La méthode implements n’est qu’une vérification en temps de compilation ; elle ne fait rien en temps de exécution. Un détail à noter lors de l’essai de ces extraits : avec strictPropertyInitialization activé, un champ déclaré comme amount: number; sans initialisation ni affectation dans un constructeur constitue en soi une erreur, il faut donc lui assigner une valeur.

    Typage structuré : la forme prime sur les déclarations

    Contrairement à Java ou C#, TypeScript est de type structuré. Une classe n’a pas besoin de déclarer

    implements Payment
    

    pour être accepté là où un Payment est attendu. Prenons un contrat avec une seule méthode :

    interface Payment {
      pay(): void;
    }
    

    et une classe qui ne le mentionne jamais :

    class UPIPayment {
      pay() {
        console.log("Paying through UPI");
      }
    }
    

    Même sans écrire

    implements Payment
    

    une fonction qui attend un Payment

    function processPayment(payment: Payment) {
      payment.pay();
    }
    

    accepte volontiers une instance de cette classe :

    const payment = new UPIPayment();processPayment(payment);
    

    Ceci se compile car UPIPayment a la forme requise :

    {
      pay(): void;
    }
    

    Pour TypeScript, il suffit que la forme corresponde ; la déclaration explicite est optionnelle.

    Alors pourquoi écrire des instructions implements ?

    Puisque le typage structuré accepte déjà les classes correspondantes, implements peut sembler redondant. Sa valeur réside dans le fait qu’il vérifie le contrat où la classe est définie, et non seulement là où elle est utilisée. Compte tenu de ce contrat

    interface Payment {
      amount: number;
      pay(): void;
    }
    

    et d’une classe qui l’implémente explicitement,

    class UPIPayment implements Payment {
      amount = 100;  pay() {
        console.log("Paying through UPI");
      }
    }
    

    supposons que quelqu’un étende ultérieurement l’interface avec une monnaie :

    interface Payment {
      amount: number;
      currency: string;
      pay(): void;
    }
    

    Le compilateur signale immédiatement que UPIPayment ne satisfait plus au contrat, en indiquant la classe elle-même. Sans cela, l’erreur apparaîtrait uniquement à un endroit d’appel éloigné, ou pas du tout.

    Points clés

    • Les enums remplacent des chaînes de caractères dispersées par une seule déclaration, transformant les fautes d’orthographe en erreurs de compilation.
    • Les unions de littéraux de chaîne constituent une alternative légère lorsque vous n’avez pas besoin d’un objet en temps de exécution.
    • Utilisez type pour la composition (unions, intersections, types mappés) et interface pour les contrats que les classes implémentent.
    • Les interfaces décrivent ce qu’un objet fournit, jamais comment, ce qui permet des implémentations interchangeables.
    • TypeScript vérifie les structures de manière structurale, donc implements est optionnel, mais il déplace les erreurs de contrat dans la définition de la classe où elles sont les plus faciles à corriger.