Accueil / Articles

Modèles de sécurité des types en TypeScript : génériques, injection de dépendances et quand un modèle est utile

Les patterns de conception et TypeScript au niveau des types pour la sécurité des types : les génériques, l’injection de dépendances, les identifiants marqués, ainsi que la manière de déterminer quand un pattern réduit la complexité.

Éditeur : REACTAPP.TOP24 min de lecture5587 mots

Sources: From Design Patterns to Type-Level Architecture — A Practical Guide from 10+ Years of Production Experience

La plupart des équipes adoptent TypeScript pour l’autocomplétion et la détection des fautes de frappe, avant de découvrir sa véritable valeur : il permet d’encoder les décisions de conception de manière à ce que le compilateur les applique. Ce guide présente les modèles orientés objet classiques ainsi que les techniques au niveau des types les plus importantes dans les grands projets frontend et full-stack, et montre comment déterminer quand un modèle en vaut la peine.

Considérez le système de types comme un outil capable de répondre à des questions architecturales :

  • Est-il vraiment possible d’obtenir cette combinaison de valeurs d’état ?
  • Cet API pourrait-il renvoyer une structure que le reste du code ne s’attend pas à voir ?
  • Un ProductId pourrait-il être transmis à une fonction qui attend un UserId ?
  • Un composant pourrait-il recevoir des propriétés contradictoires ?
  • Si un nouvel état est ajouté, tous les composants qui l’utilisent seront-ils contraints de le gérer ?
  • Est-il possible de remplacer une implémentation sans toucher à ses appelsants ?
  • Est-il possible de réutiliser une abstraction sans recourir à any ?
  • Un motif n’est pas une fonctionnalité que l’on ajoute ultérieurement. C’est un nom donné à une solution que l’on reconnaît dès qu’un problème récurrent apparaît.

    Quatre familles de motifs, plus le système de types

    La classification classique comprend les motifs de création, de structure et de comportement ; TypeScript ajoute une quatrième famille de techniques au niveau des types, basées sur les génériques, les unions et des outils de sécurité.

                         TYPESCRIPT PATTERNS
                                    │
            ┌───────────────────────┼────────────────────────┐
            │                       │                        │
            ▼                       ▼                        ▼
       CREATIONAL              STRUCTURAL                BEHAVIORAL
            │                       │                        │
            ├─ Factory              ├─ Adapter              ├─ Strategy
            ├─ Builder              ├─ Facade               ├─ Observer
            ├─ Singleton            ├─ Decorator            ├─ Command
            └─ Abstract Factory     ├─ Repository           └─ State
                                    └─ Composition
    
                                    +
    
                        TYPESCRIPT TYPE PATTERNS
                                    │
            ┌───────────────────────┼────────────────────────┐
            │                       │                        │
            ▼                       ▼                        ▼
        Generics                Unions                  Type Safety
            │                       │                        │
            ├─ Constraints          ├─ Discriminated         ├─ Type Guards
            ├─ keyof                  Unions                ├─ Branded Types
            ├─ typeof               ├─ Result Types          ├─ Exhaustiveness
            ├─ infer                └─ State Modeling        └─ satisfies
            └─ Mapped Types
    

    Les applications réelles utilisent rarement un seul schéma. Un flux de données typique de React empile plusieurs d’entre eux, et chaque frontière peut comporter des types précis :

    React Component
          │
          ▼
    Custom Hook
          │
          ▼
    Service
          │
          ▼
    Repository
          │
          ▼
    API Client
          │
          ▼
    Result<T, E>
          │
          ▼
    Discriminated Union
    

    Lorsque les types circulent à travers toute cette chaîne, TypeScript devient une description de la manière dont le système est autorisé à se comporter.

    Partez du problème, pas du schéma

    Un piège courant est de raisonner dans la mauvaise direction :

    "I know Factory Pattern.
    Where can I use Factory?"
    

    Choisir un modèle en premier lieu crée des abstractions dont personne n’a besoin. Laissez plutôt le problème guider le choix :

    What problem do I have?
            ↓
    Where is the complexity?
            ↓
    What is changing frequently?
            ↓
    What should remain stable?
            ↓
    What abstraction reduces that complexity?
            ↓
    Is a known pattern appropriate?
    

    Considérez un flux de paiement qui comporte une série de vérifications concernant la méthode de paiement :

    if (paymentMethod === "card") {
      // ...
    }
    
    if (paymentMethod === "paypal") {
      // ...
    }
    if (paymentMethod === "upi") {
      // ...
    }
    

    La réponse réflexe est souvent la suivante :

    Don’t immediately think:
    

    « Cela nécessite une Stratégie. » Avant de l’appliquer, demandez-vous si les branches sont vraiment des algorithmes interchangeables derrière un même contrat. S’ils le sont, une Stratégie convient. Si le véritable problème est que certains champs n’ont de sens que pour certaines méthodes, une union discriminée qui rend les combinaisons invalides irreprésentables pourrait être l’outil le plus adapté. Connaître le catalogue est facile ; savoir associer une entrée à une pression spécifique, c’est là la véritable compétence.

    Modèles de création

    Singleton : une seule instance partagée

    Un Singleton garantit qu’une classe dispose d’une seule instance. Le constructeur privé empêche toute création en dehors de new, et une méthode statique crée et stocke cette instance de manière différée :

    class Logger {
      private static instance: Logger;
    
    private constructor() {}
      static getInstance(): Logger {
        if (!Logger.instance) {
          Logger.instance = new Logger();
        }
        return Logger.instance;
      }
      log(message: string) {
        console.log(message);
      }
    }
    

    Les appels demandent l’objet partagé au lieu de en créer un :

    const logger = Logger.getInstance();
    logger.log("Application started");
    

    Chaque consommateur finit par pointer vers le même objet :

                      Logger
                        │
                 getInstance()
                        │
                        ▼
                 ┌───────────┐
                 │  Logger   │
                 │ Instance  │
                 └───────────┘
                   ▲       ▲
                   │       │
              Service A  Service B
    

    Les candidats pertinents incluent :

    • les systèmes de journalisation
    • les responsables de l’analyse de données
    • ceux qui gèrent la configuration
    • certains gestionnaires de connexions
    • d’autres services d’infrastructure transversaux

    Le problème, c’est qu’un Singleton représente en réalité un accès global déguisé : il rend les tests plus difficiles à isoler, fait disparaître les dépendances des signatures, complique le cycle de vie des objets, et provoque des modifications de l’état mutable partagé de manière impossible à tracer. Dans un frontend moderne, une instance exportée depuis un module ES est déjà partagée, et l’injection de dépendances, React Context ou une bibliothèque de gestion d’état offrent la même garantie avec une structure visible.

    Factory : cacher quelle classe est créée

    Une Factory permet de déplacer la décision quant à la classe concrète à instancier loin du consommateur. Comparez avec la construction directe :

    const payment = new StripePayment();
    

    avec construction déléguée :

    const payment = PaymentFactory.create("stripe");
    

    Les deux fournisseurs implémentent une même interface, et la factory associe une union de littéraux de chaîne à la classe appropriée. Comme le type du paramètre est "stripe" | "paypal", tout nom non pris en charge entraîne une compilation échouée :

    interface PaymentProvider {
      pay(amount: number): Promise<void>;
    }
    
    class StripePayment implements PaymentProvider {
      async pay(amount: number) {
        console.log("Stripe:", amount);
      }
    }
    class PayPalPayment implements PaymentProvider {
      async pay(amount: number) {
        console.log("PayPal:", amount);
      }
    }
    class PaymentFactory {
      static create(
        provider: "stripe" | "paypal"
      ): PaymentProvider {
        switch (provider) {
          case "stripe":
            return new StripePayment();
          case "paypal":
            return new PayPalPayment();
        }
      }
    }
    

    Visuellement, l’usine est représentée par une fourche identifiée par le nom du fournisseur :

                  PaymentFactory
                           │
              ┌────────────┴────────────┐
              │                         │
           "stripe"                  "paypal"
              │                         │
              ▼                         ▼
         StripePayment             PayPalPayment
    

    Une usine est utile lorsque :

    • la construction repose sur une logique réelle
    • plusieurs implémentations partagent un même contrat
    • les consommateurs ne doivent pas connaître les classes concrètes
    • les implémentations doivent évoluer indépendamment des appelsants

    Pour une construction triviale comme celle ci-dessous, cela ne fait qu’ajouter une couche supplémentaire à parcourir :

    new User();
    

    Usine abstraite : familles d’objets liés

    L’usine abstraite étend cette idée à des groupes d’objets qui doivent être compatibles. Dans un kit d’interface visuelle destiné à plusieurs plateformes, un bouton web ne doit jamais être associé à un modal mobile ; c’est pourquoi chaque usine génère une famille cohérente :

    interface Button {
      render(): void;
    }
    
    interface Modal {
      open(): void;
    }
    
    interface UIFactory {
      createButton(): Button;
      createModal(): Modal;
    }
    
                      UIFactory
                        │
               ┌────────┴────────┐
               ▼                 ▼
         WebUIFactory       MobileUIFactory
               │                 │
          ┌────┴────┐       ┌────┴────┐
          ▼         ▼       ▼         ▼
       Button     Modal   Button     Modal
    

    C’est puissant, mais il est facile de le surcharger inutilement. Dans la plupart des applications frontend, la simple composition de composants suffit pour y parvenir sans trop de formalités.

    Builder : construction contrôlée, étape par étape

    Le Builder est utile lorsque un objet dispose de nombreuses options facultatives ou est configuré en plusieurs étapes. Chaque méthode de mise à jour modifie la configuration privée et renvoie this, ce qui permet la chaînage des opérations :

    class RequestBuilder {
      private config: RequestInit = {};
    
    setMethod(method: string) {
        this.config.method = method;
        return this;
      }
      setHeaders(headers: HeadersInit) {
        this.config.headers = headers;
        return this;
      }
      setBody(body: BodyInit) {
        this.config.body = body;
        return this;
      }
      build() {
        return this.config;
      }
    }
    

    Assembler une demande se présente sous forme d’étapes explicites :

    const request = new RequestBuilder()
      .setMethod("POST")
      .setHeaders({
        "Content-Type": "application/json"
      })
      .setBody(JSON.stringify(data))
      .build();
    

    Une syntaxe fluide est un effet secondaire, non la finalité. L’objectif est de garder les constructions complexes explicites et regroupées en un seul endroit, où build() peut également les valider, par exemple en refusant un corps dans une requête GET.

    Modèles structuraux

    Adapter : un contrat stable autour de l’API d’un tiers

    Adapter est l’un des modèles les plus utilisés dans le code d’application. Supposons que votre code attende ce contrat interne :

    interface PaymentGateway {
      pay(amount: number): Promise<void>;
    }
    

    alors qu’un SDK de tiers expose une méthode au nom différent :

    class LegacyPaymentSDK {
      makePayment(value: number) {
        // third-party implementation
      }
    }
    

    Un adaptateur fin met en œuvre votre interface et traduit les appels :

    class PaymentAdapter implements PaymentGateway {
      constructor(
        private readonly sdk: LegacyPaymentSDK
      ) {}
    
    async pay(amount: number) {
        this.sdk.makePayment(amount);
      }
    }
    
    Application
         │
         ▼
    PaymentGateway
         ▲
         │
    PaymentAdapter
         │
         ▼
    Third-party SDK
    

    L’application dépend maintenant d’une interface que vous possédez :

    PaymentGateway
    

    plutôt que pour chaque fournisseur qui se trouve derrière lui :

    Stripe
    PayPal
    LegacySDK
    SomeFutureProvider
    

    Soutenir un nouveau fournisseur signifie écrire un autre adaptateur.

    Facade : une seule appel pour un flux de travail en plusieurs étapes

    Une Facade crée un point d’entrée simple devant un sous-système complexe. Le processus de connexion peut impacter plusieurs services :

    Authentication
         +
    User Service
         +
    Permissions
         +
    Notification
         +
    Analytics
    

    Sans façade, chaque composant qui authentifie un utilisateur orchestre lui-même la séquence :

    auth.login();
    user.load();
    permission.load();
    analytics.track();
    

    Une façade entoure cette orchestration une seule fois :

    class AppFacade {
      async login(username: string, password: string) {
        const token = await auth.login(username, password);
    
    const user = await userService.getUser(token);
        await permissionService.load(user);
        analytics.track("login");
        return user;
      }
    }
    

    et le composant se réduit à une seule appel :

    await appFacade.login(username, password);
    
                   Component
                         │
                         ▼
                     AppFacade
                         │
           ┌─────────────┼─────────────┐
           ▼             ▼             ▼
         Auth          User        Permission
        Service       Service       Service
    

    Cela est utile lorsque l’ordre des étapes est important.

    Decorator : ajouter du comportement en enrobant

    Un Decorator ajoute du comportement sans modifier l’implémentation originale, car l’objet d’enrobage et l’objet original partagent une même interface. Le contrat :

    interface Logger {
      log(message: string): void;
    }
    

    Une implémentation simple :

    class ConsoleLogger implements Logger {
      log(message: string) {
        console.log(message);
      }
    }
    

    Un décorateur qui contient n’importe quel Logger et ajoute une date et une heure en en-tête des messages :

    class TimestampLogger implements Logger {
      constructor(
        private readonly logger: Logger
      ) {}
    
    log(message: string) {
        this.logger.log(
          `[${new Date().toISOString()}] ${message}`
        );
      }
    }
    

    Le emballage n’est rien d’autre qu’une construction :

    const logger = new TimestampLogger(
      new ConsoleLogger()
    );
    

    Puisque chaque décorateur est lui-même un Logger, ils s’empilent :

    Logger
      │
      ▼
    ConsoleLogger
      │
      ▼
    TimestampLogger
      │
      ▼
    AdditionalDecorator
    

    La même idée apparaît sous d’autres noms :

    • chaînes de middleware
    • fonctions d’enveloppement
    • composants de haut niveau de React
    • journalisation
    • couches de mise en cache
    • vérifications d’autorisation
    • instrumentation et traçage

    Modèles de comportement

    Stratégie : algorithmes interchangeables

    La stratégie élimine les règles métier à branches. Prenons un type de niveau de client :

    type CustomerType =
      | "regular"
      | "premium"
      | "enterprise";
    

    Une implémentation conditionnelle place chaque règle dans une seule fonction :

    function calculateDiscount(
      type: CustomerType,
      price: number
    ) {
      if (type === "regular") {
        return price;
      }
    
    if (type === "premium") {
        return price * 0.9;
      }
      return price * 0.8;
    }
    

    Avec Strategy, chaque règle devient une classe derrière une interface commune :

    interface DiscountStrategy {
      calculate(price: number): number;
    }
    
    class RegularDiscount implements DiscountStrategy {
      calculate(price: number) {
        return price;
      }
    }
    class PremiumDiscount implements DiscountStrategy {
      calculate(price: number) {
        return price * 0.9;
      }
    }
    class EnterpriseDiscount implements DiscountStrategy {
      calculate(price: number) {
        return price * 0.8;
      }
    }
    
                      Order
                          │
                          ▼
                  DiscountStrategy
                          │
              ┌───────────┼───────────┐
              ▼           ▼           ▼
           Regular      Premium    Enterprise
           Strategy     Strategy    Strategy
    

    Ajouter un niveau maintenant signifie généralement introduire une nouvelle stratégie plutôt que d’éditer la logique existante, ce qui correspond en pratique au principe ouvert/fermé. Avec seulement trois règles simples, une condition suffit ; la stratégie devient utile lorsque les règles développent leurs propres dépendances ou tests.

    Observer : notifications un-à-plusieurs

    Observer permet à un sujet d’informer un nombre quelconque d’écouteurs lorsqu’il y a un changement :

                     Subject
                         │
              ┌──────────┼──────────┐
              ▼          ▼          ▼
          Observer A Observer B Observer C
    

    Un petit émetteur en mode saisie stocke les écouteurs dans un Set et renvoie une fonction de nettoyage lorsqu’un écouteur est enregistré :

    type Listener<T> = (value: T) => void;
    
    class EventEmitter<T> {
      private listeners = new Set<Listener<T>>();
      subscribe(listener: Listener<T>) {
        this.listeners.add(listener);
        return () => {
          this.listeners.delete(listener);
        };
      }
      emit(value: T) {
        this.listeners.forEach(listener => {
          listener(value);
        });
      }
    }
    
    const emitter = new EventEmitter<string>();
    
    const unsubscribe = emitter.subscribe(message => {
      console.log(message);
    });
    emitter.emit("Hello");
    unsubscribe();
    

    Cette fonction de nettoyage fait partie de la gestion du cycle de vie. Oublier de l’appeler entraîne :

    • des fuites de mémoire dues à des écouteurs qui survivent à leurs propriétaires
    • un traitement dupliqué lorsque deux écouteurs sont attachés
    • des closures obsolètes qui lisent des valeurs dépassées
    • des effets secondaires qui se produisent après la disparition d’un composant

    En React, c’est exactement ce que l’on retourne depuis la fonction de rappel de useEffect.

    Commande : actions en tant qu’objets

    Commande transforme une action en un objet doté d’une interface uniforme :

    interface Command {
      execute(): void;
    }
    

    Des commandes concrètes l’implémentent :

    class SaveCommand implements Command {
      execute() {
        console.log("Saving...");
      }
    }
    
    class UndoCommand implements Command {
      execute() {
        console.log("Undo");
      }
    }
    

    Considérer les actions comme des valeurs est utile lorsque vous avez besoin de :

    • un historique de ce qui s’est passé
    • la possibilité d’annuler et de réexécuter
    • une exécution en file d’attente
    • des tentatives de répétition
    • une exécution différée
    • un journal d’audit

    Une véritable fonction d’annulation exige généralement que chaque commande s’inverse elle-même, c’est pourquoi les versions en production ajoutent souvent une méthode undo() à côté de execute().

    User Action
        │
        ▼
     Command
        │
        ├── execute()
        │
        ▼
     Receiver
    

    Modèles de données et de composition

    Repository : isoler l’accès aux données

    Lorsque l’accès aux données devient complexe, un Repository crée un contrat entre la logique métier et tout élément qui stocke ou récupère les données :

    UI
     │
     ▼
    Hook / Controller
     │
     ▼
    Service
     │
     ▼
    Repository
     │
     ├── REST
     ├── GraphQL
     ├── IndexedDB
     └── Cache
    

    Le contrat décrit ce que l’application peut demander :

    interface UserRepository {
      getUser(id: string): Promise<User>;
      getUsers(): Promise<User[]>;
    }
    

    Une implémentation communique avec une API REST :

    class ApiUserRepository implements UserRepository {
      async getUser(id: string) {
        const response = await fetch(`/users/${id}`);
    
    return response.json();
      }
      async getUsers() {
        const response = await fetch("/users");
        return response.json();
      }
    }
    

    Le code d’entreprise dépend de l’interface :

    UserRepository
    

    pas en transport :

    fetch()
    axios()
    graphqlClient()
    

    Cela est important lorsque la source change : du REST au GraphQL, l’ajout d’un cache IndexedDB, ou une version en mémoire pour les tests. Notez que response.json() renvoie une valeur non typée, donc le repository est également l’endroit idéal pour valider les réponses.

    Composition plutôt qu’héritage

    Dans le développement frontend, la composition est plus importante que tout schéma basé sur l’héritage. Plutôt que d’avoir un seul composant qui gère tout :

    MegaComponent
     ├── Authentication
     ├── Table
     ├── Filters
     ├── Modal
     ├── Notifications
     ├── API calls
     └── Business logic
    

    Divisez les responsabilités en tâches bien définies :

    Dashboard
     ├── Header
     ├── Sidebar
     ├── FilterPanel
     ├── DataTable
     └── NotificationPanel
    

    Dans React, il s’agit simplement de superposer des composants :

    <Dashboard>
      <Header />
      <Sidebar />
      <MainContent />
    </Dashboard>
    

    Chaque élément peut être compris, testé et remplacé indépendamment. Pour en savoir plus sur la décomposition des composants trop volumineux, consultez comment résoudre le surcharge des props de React grâce à la composition et aux slots.

    Modélisation de l’état avec le système de types

    Désormais, TypeScript lui-même constitue l’outil architectural.

    Unions discriminées pour l’état des requêtes

    Une requête traverse différentes phases qui contiennent des données variées. Une union discriminée attribue à chaque phase une structure propre, reliées entre elles par un champ littéral status :

    type RequestState =
      | {
          status: "idle";
        }
      | {
          status: "loading";
        }
      | {
          status: "success";
          data: User[];
        }
      | {
          status: "error";
          error: string;
        };
    

    En activant le discriminant, on restreint le type dans chaque branche :

    function render(state: RequestState) {
      switch (state.status) {
        case "idle":
          return "Nothing started";
    
        case "loading":
          return "Loading...";
        case "success":
          return state.data;
        case "error":
          return state.error;
      }
    }
    

    Le compilateur suit quels champs existent après chaque vérification :

    status = "success"
            ↓
    data exists
    
    status = "error"
            ↓
    error exists
    

    Comparez le modèle booléen et optionnel courant :

    interface State {
      loading: boolean;
      data?: User[];
      error?: string;
    }
    

    Rien ne l’empêche de décrire un état qui ne devrait jamais survenir :

    {
      loading: true,
      data: [...],
      error: "Something failed"
    }
    

    Le syndicat rend ces combinaisons très difficiles à exprimer. Modélisez les états valides plutôt que de disperser des propriétés optionnelles en comptant sur chacun pour les combiner correctement.

    Types de résultats pour une défaillance explicite

    De nombreuses opérations ont exactement deux résultats :

    Success
       OR
    Failure
    

    Le type Result est entièrement spécifié par un discriminant booléen :

    type Result<T, E> =
      | {
          success: true;
          data: T;
        }
      | {
          success: false;
          error: E;
        };
    

    Une fonction qui le renvoie :

    function getUser(): Result<User, string> {
      return {
        success: true,
        data: user
      };
    }
    

    Le appelant doit vérifier la valeur de success avant d’accéder à data ou error :

    const result = getUser();
    
    if (result.success) {
      console.log(result.data);
    } else {
      console.error(result.error);
    }
    
                 Service
                    │
                    ▼
              Result<T, E>
               /       \
              /         \
             ▼           ▼
          Success      Failure
            │             │
           data          error
    

    Cela correspond aux échecs commerciaux attendus tels que « adresse e-mail déjà enregistrée ». Les exceptions s’appliquent néanmoins aux véritables bugs et aux pannes d’infrastructure ; le type Result permet simplement de rendre visibles les échecs prévus dans la signature.

    Generiques et autres outils au niveau des types

    Les generiques conservent le lien entre l’entrée et la sortie

    L’utilisation de any jette l’information away :

    function identity(value: any): any {
      return value;
    }
    

    Un paramètre de type le conserve :

    function identity<T>(value: T): T {
      return value;
    }
    

    Le résultat déduit suit l’argument suivant :

    const a = identity("hello");
    // string
    
    const b = identity(100);
    // number
    

    La relation entre l’entrée et la sortie persiste :

    Input T
      │
      ▼
    Function<T>
      │
      ▼
    Output T
    

    Les génériques permettent également de réutiliser des contrats partagés. Une seule enveloppe de réponse :

    interface ApiResponse<T> {
      data: T;
      status: number;
      message: string;
    }
    

    décrivait de nombreux chargements utiles :

    type UserResponse =
      ApiResponse<User>;
    
    type ProductResponse =
      ApiResponse<Product>;
    

    Contraintes : exigence d’une forme

    Ceci échoue à se compiler :

    function getId<T>(item: T) {
      return item.id;
    }
    

    car rien n’indique à TypeScript que T possède un id. Une contrainte ajoute cette garantie :

    function getId<T extends { id: string }>(
      item: T
    ) {
      return item.id;
    }
    

    Tout objet disposant d’une chaîne id est accepté, y compris les champs supplémentaires :

    getId({
      id: "123",
      name: "Hareesh"
    });
    

    Lisez la contrainte de cette manière :

    T can be anything
    BUT
    T must have id: string
    

    keyof et accès par index

    Étant donné une interface :

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

    keyof produit l’union de ses noms de propriétés :

    type UserKey = keyof User;
    
    "id" | "name" | "age"
    

    En combinant keyof avec un deuxième paramètre de type et le type d’accès indexé T[K], on obtient un accesseur dont le type de retour correspond à la clé :

    function getProperty<T, K extends keyof T>(
      object: T,
      key: K
    ): T[K] {
      return object[key];
    }
    
    const user = {
      id: "1",
      name: "Hareesh",
      age: 30
    };
    
    getProperty(user, "name");
    

    Une clé réelle compile :

    getProperty(user, "name");
    

    Une clé manquante est une erreur de compilation :

    getProperty(user, "salary");
    

    La force provient de trois outils qui fonctionnent ensemble :

    Generics
       +
    keyof
       +
    Indexed Access
    

    Types mappés : transformer un type existant

    Les types mappés itèrent sur les clés d’un type pour en créer un nouveau. À partir de :

    interface User {
      id: string;
      name: string;
      email: string;
    }
    

    Vous pouvez obtenir une version où tous les éléments sont optionnels :

    type OptionalUser = {
      [K in keyof User]?: User[K];
    };
    

    équivalent conceptuellement à l’écriture :

    {
      id?: string;
      name?: string;
      email?: string;
    }
    

    C’est ainsi que des fonctionnalités intégrées comme Partial sont définies.

    Types conditionnels : décisions au niveau du type

    Les types conditionnels choisissent entre deux types en fonction de leur assignabilité :

    T extends U ? X : Y
    

    Celui-ci déballle les types d’éléments d’array et laisse tout le reste intact :

    type Flatten<T> =
      T extends Array<infer U>
        ? U
        : T;
    
    type A = Flatten<string[]>;
    // string
    
    type B = Flatten<number>;
    // number
    

    À ce stade, le système de types se comporte comme un petit langage de compilation, ce qui exige retenue.

    infer : extraire une partie d’un type

    Dans un type conditionnel, infer déclare une variable de type que TypeScript remplit par correspondance. Cela réimplémente le ReturnType intégré :

    type MyReturnType<T> =
      T extends (...args: any[]) => infer R
        ? R
        : never;
    

    Associez-le à typeof pour déduire un type à partir d’une fonction existante, afin qu’il ne s’écarte jamais de l’implémentation :

    function getUser() {
      return {
        id: "1",
        name: "Hareesh"
      };
    }
    
    type User = MyReturnType<typeof getUser>;
    

    Les définitions des types de bibliothèque dépendent fortement de cela.

    Les types d’outil intégrés en premier

    Avant d’écrire des outils intelligents, connaissez ce qui est fourni avec le langage :

    Partial
    Required
    Readonly
    Pick
    Omit
    Record
    Exclude
    Extract
    NonNullable
    ReturnType
    Parameters
    InstanceType
    Awaited
    

    Supprimer un champ sensible, par exemple, se fait en une seule ligne au lieu d’une interface dupliquée qui risquerait de devenir désynchronisée :

    interface User {
      id: string;
      name: string;
      email: string;
      password: string;
    }
    
    type PublicUser =
      Omit<User, "password">;
    

    Le guide sur les types utilitaires intégrés de TypeScript aborde chacun d’eux en profondeur.

    Record avec un ensemble fermé de clés

    Record convient aux recherches et à la configuration, en particulier lorsque les clés proviennent d’une union littérale :

    type Permission =
      "read" |
      "write" |
      "delete";
    
    type PermissionMap =
      Record<Permission, boolean>;
    
    const permissions: PermissionMap = {
      read: true,
      write: false,
      delete: false
    };
    

    Si l’on omet une autorisation, TypeScript signale la clé manquante. Un type de clé large perd cette garantie :

    const permissions: Record<string, boolean>
    

    car Record<string, boolean> accepte pratiquement n’importe quelle clé de type chaîne, de sorte que les omissions et les fautes d’orthographe passent inaperçues.

    Sécurité de type pour les identifiants et les données non fiables

    Types spécifiques pour les identifiants de domaine

    Deux identifiants peuvent être des chaînes tout en ayant des significations différentes :

    const userId: string;
    const productId: string;
    

    D’un point de vue structurel, TypeScript ne peut pas les distinguer. L’intersection d’un string avec une propriété fantôme crée des types distincts :

    type UserId =
      string & {
        readonly __brand: "UserId";
      };
    
    type ProductId =
      string & {
        readonly __brand: "ProductId";
      };
    

    Les fonctions peuvent alors exiger le type d’ID approprié :

    function getUser(id: UserId) {}
    
    function getProduct(id: ProductId) {}
    

    Passer un ProductId là où un UserId est attendu provoque une erreur de compilation. La marque n’existe jamais en temps de exécution ; vous créez des valeurs liées à une marque à l’aide d’un petit constructeur ou d’une fonction de validation qui effectue le casting en un seul endroit. Conceptuellement :

    string
      │
      ├── UserId
      ├── ProductId
      ├── OrderId
      └── TransactionId
    

    Cela s’avère avantageux dans les systèmes volumineux où des dizaines d’identifiants partagent un même type primitif.

    Guardes de type pour des entrées inconnues

    Les données provenant du réseau, du stockage ou des entrées utilisateur doivent arriver sous la forme de unknown :

    const data: unknown = await response.json();
    

    Un casting est le raccourci tentant :

    const user = data as User;
    

    Restreignez la valeur en utilisant un garde de type défini par l’utilisateur ; son type de retour value is User indique au compilateur ce qu’un résultat true prouve :

    function isUser(
      value: unknown
    ): value is User {
      return (
        typeof value === "object" &&
        value !== null &&
        "id" in value &&
        "name" in value
      );
    }
    
    if (isUser(data)) {
      console.log(data.name);
    }
    

    N’oubliez pas que les types sont effacés en temps de exécution. Une interface comme celle-ci :

    interface User {
      id: string;
    }
    

    Ce n’est pas une validation de ce que l’API envoie. La vérification ci-dessus ne s’assure que des clés existent, pas de leurs types ; par conséquent, pour des entrées non fiables, associez TypeScript à un validateur de schéma en temps de exécution.

    Vérification exhaustive avec never

    L’une des combinaisons les plus efficaces dans ce langage :

    Discriminated Union
            +
    never
            +
    switch
    

    Prenez une union de statut :

    type Status =
      | "loading"
      | "success"
      | "error";
    

    et un outil d’aide qui n’accepte que never :

    function assertNever(
      value: never
    ): never {
      throw new Error(
        `Unexpected value: ${value}`
      );
    }
    

    Lorsque chaque membre a été traité, la branche par défaut affiche le type never, ce qui permet à l’appel de se compiler :

    function render(status: Status) {
      switch (status) {
        case "loading":
          return "Loading";
        case "success":
          return "Success";
        case "error":
          return "Error";
        default:
          return assertNever(status);
      }
    }
    

    Un coéquipier étend maintenant l’union :

    Now imagine someone adds:"cancelled" to Status.
    

    La branche par défaut reçoit la valeur "cancelled", qui ne peut pas être assignée à never, ce qui provoque l’échec de la compilation tant que ce cas n’a pas été géré. Le compilateur agit alors comme un examinateur de conception qui se souvient de chaque consommateur.

    Types de littéraux de template

    TypeScript peut composer des types de littéraux de chaîne à partir d’autres types :

    type Entity =
      "user" |
      "order" |
      "product";
    
    type Event =
      `${Entity}:created` |
      `${Entity}:updated` |
      `${Entity}:deleted`;
    

    L’union résultante contient toutes les combinaisons :

    user:created
    user:updated
    user:deleted
    
    order:created
    order:updated
    order:deleted
    
    product:created
    product:updated
    product:deleted
    

    Les utilisations pratiques incluent :

    • les noms d’événements
    • les clés d’événement d’analyse
    • les chaînes de permissions
    • les modèles de routes
    • les noms des flags de fonctionnalité
    • les tokens du système de conception

    as const lorsque les valeurs constituent la source de vérité

    Un littéral d’array de chaînes est élargi :

    const roles = [
      "admin",
      "editor",
      "viewer"
    ];
    

    Son type est string[]. L’ajout de as const génère une tuple lisible seule composée de littéraux :

    const roles = [
      "admin",
      "editor",
      "viewer"
    ] as const;
    

    d’où suit directement un type union :

    type Role =
      typeof roles[number];
    
    becomes:
    
    
    "admin" |
    "editor" |
    "viewer"
    

    Définissez les valeurs une seule fois et ne maintenez jamais manuellement une union parallèle.

    satisfies pour la configuration vérifiée

    satisfies valide une expression par rapport à un type sans l’élargir à ce type :

    type Config = {
      retries: number;
      environment:
        | "development"
        | "production";
    };
    
    const config = {
      retries: 3,
      environment: "production"
    } satisfies Config;
    

    L’objet est vérifié par rapport à Config, mais config.environment conserve le type littéral "production", ce que ne ferait pas une simple annotation. Cela convient à :

    • la configuration des routes
    • les flags de fonctionnalité
    • les tokens de design
    • les objets de paramètres statiques
    • les cartes de permissions

    Injection de dépendances sans framework

    Créer des dépendances à l’intérieur d’une classe la lie à celles-ci :

    class UserService {
      private api = new ApiClient();
    }
    

    Le fait de les recevoir via le constructeur permet à la classe de rester agnostique :

    class UserService {
      constructor(
        private readonly api: ApiClient
      ) {}
    }
    

    La production est transmise au client réel :

    const service =
      new UserService(apiClient);
    

    et teste un mock :

    const service =
      new UserService(mockApiClient);
    
                    UserService
                          ▲
                          │
                     Dependency
                          │
                 ┌────────┴────────┐
                 ▼                 ▼
            ApiClient         MockApiClient
           Production            Testing
    

    C’est l’un des moyens les moins coûteux pour obtenir du code testable, et les paramètres de constructeur suffisent à cet effet.

    Distinguer des schémas similaires

    Pattern d’état ou union discriminée ?

    Étant donné le cycle de vie d’une commande :

    Draft
    Paid
    Shipped
    Cancelled
    

    Une union discriminée peut être tout ce dont vous avez besoin :

    type Order =
      | { status: "draft" }
      | { status: "paid" }
      | { status: "shipped" }
      | { status: "cancelled" };
    

    Mais lorsque chaque état présente un comportement significatif :

    Draft
     ├── edit()
     ├── submit()
    
    Paid
     ├── refund()
     ├── ship()
    Shipped
     ├── track()
     └── deliver()
    

    Un pattern d’état, où chaque objet d’état implémente les opérations autorisées, pourrait être plus adapté. Décidez en fonction du niveau de complexité de chaque état, et non en fonction de la terminologie.

    Stratégie ou état ?

    C’est un sujet fréquent lors des entretiens. Avec la stratégie, c’est le client qui choisit l’algorithme :

    Order
      │
      ▼
    Strategy
      ├── CreditCard
      ├── PayPal
      └── UPI
    

    Avec State, l’objet modifie son propre comportement au fur et à mesure qu’il traverse son cycle de vie :

    Order
      │
      ├── Draft
      ├── Paid
      └── Shipped
    

    La stratégie détermine la manière dont une tâche est exécutée ; l’état détermine ce qu’un objet fait à son stade actuel.

    Usine ou stratégie ?

    Une usine répond à la question « quel objet doit être créé ? » :

    PaymentFactory.create("stripe");
    

    Une stratégie répond à la question « quel comportement doit être exécuté ? » :

    new Order(discountStrategy);
    

    Ils s’associent naturellement : une usine produit la stratégie qui effectue le travail :

    Factory
       ↓
    creates
       ↓
    Strategy
       ↓
    executes behavior
    

    Application des modèles en React

    Propriétés variantes au lieu de flags booléens

    Les booléens indépendants permettent des situations absurdes, comme un bouton qui serait à la fois principal et dangereux :

    interface ButtonProps {
      primary?: boolean;
      danger?: boolean;
      loading?: boolean;
    }
    

    Une union de variantes permet à chacun d’indiquer ses propres exigences :

    type ButtonProps =
      | {
          variant: "primary";
          loading?: boolean;
        }
      | {
          variant: "danger";
          confirmationRequired: boolean;
        };
    

    La variante dangereuse doit maintenant spécifier confirmationRequired.

    API des composants qui rejettent les contradictions

    Un composant qui affiche soit un lien soit un bouton avec des attributs href et onClick optionnels permettrait l’utilisation des deux ou de aucun des deux. Le fragment ci-dessous montre la version lâche, l’alternative discriminée ainsi qu’une utilisation valide :

    {
      href?: string;
      onClick?: () => void;
    }
    
    use:
    type ActionProps =
      | {
          type: "link";
          href: string;
        }
      | {
          type: "button";
          onClick: () => void;
        };
    
    
    Now:
    
    <Action
      type="link"
      href="/users"
    />
    

    Une variante de lien disposant d’un gestionnaire de clic plutôt que d’un href est rejetée :

    <Action
      type="link"
      onClick={...}
    />
    

    Les modes pris en charge, expliqués simplement :

    Link
    Button
    

    TypeScript fait désormais partie de l’architecture des composants plutôt que de documents qui deviennent obsolètes.

    Un bus d’événements sécurisé côté types

    Commencez par une table reliant les noms des événements aux types de données associés :

    type Events = {
      "user:created": User;
      "user:deleted": UserId;
      "order:created": Order;
    };
    

    Un dictionnaire générique sur cette carte relie chaque nom à son contenu via keyof et T[K] :

    class EventBus<T extends Record<string, unknown>> {
      on<K extends keyof T>(
        event: K,
        handler: (data: T[K]) => void
      ) {
        // implementation
      }
    
    emit<K extends keyof T>(
        event: K,
        data: T[K]
      ) {
        // implementation
      }
    }
    

    Le payload correct se compile :

    bus.emit("user:created", user);
    

    Celui qui est incorrect ne fait pas :

    bus.emit("user:created", order);
    

    Plusieurs outils se retrouvent ici :

    Generics
    +
    keyof
    +
    Indexed Access
    +
    Mapped Type thinking
    

    Types dans toute la couche API

    Un frontend mature organise souvent l’accès aux données de cette manière :

    Component
         ↓
    Hook
         ↓
    Service
         ↓
    Repository
         ↓
    API Client
         ↓
    HTTP
    

    avec des types conservés à chaque étape. Un répertoire peut accepter des identifiants de marque et retourner un Result :

    type ApiResponse<T> = {
      data: T;
      status: number;
    };
    
    interface UserRepository {
      getUser(id: UserId):
        Promise<Result<User, ApiError>>;
    }
    

    Le composant ne gère plus cela à chaque couche :

    any
    

    Les signatures elles-mêmes indiquent ce qui peut réussir, ce qui peut échouer et avec quels types.

    Anti-modèles à éviter

    any partout

    function process(data: any) {}
    

    Un paramètre any désactive la vérification de tout ce qu’il touche. Préférez :

    function process(data: unknown) {}
    

    et le resserrez avant utilisation.

    Assertions en tant que validation

    const user =
      response.data as User;
    

    Un cast as ne vérifie rien ; il demande simplement au compilateur de vous faire confiance. Validez les données non fiables en temps de exécution.

    Les génériques pour eux-mêmes

    Évitez des signatures comme :

    function transform<
      T,
      U,
      V,
      R
    >(...) {}
    

    à moins que chaque paramètre de type ne représente une relation réelle. Un paramètre de type utilisé une seule fois est généralement inutile.

    Unions énormes

    Une union discriminée comptant une centaine de membres est difficile à naviguer et à faire évoluer. Le domaine peut avoir besoin d’une abstraction différente, telle que des unions imbriquées ou le transfert de la variation dans les données.

    Ingéniosité au niveau du type

    Lorsqu’un type est plus difficile à comprendre que la logique métier qu’il décrit, faites un pas en arrière :

    Type complexity
           │
           ▼
    Developer complexity
           │
           ▼
    Maintenance cost
    

    La sécurité de type a un coût. Visez la sécurité la plus utile par unité de complexité, et non les types les plus sophistiqués.

    Un arbre de décision pour choisir

    Cet arbre associe les pressions courantes aux modèles possibles :

    PROBLEM
       │
       ├── Need to create objects?
       │       ├── Simple creation → Constructor
       │       ├── Complex creation → Builder
       │       ├── Multiple implementations → Factory
       │       └── Families of objects → Abstract Factory
       │
       ├── Need to integrate another system?
       │       └── Adapter
       │
       ├── Complex subsystem?
       │       └── Facade
       │
       ├── Add behavior without modifying object?
       │       └── Decorator
       │
       ├── Multiple interchangeable algorithms?
       │       └── Strategy
       │
       ├── Subscribers react to changes?
       │       └── Observer
       │
       ├── Need actions/history/undo?
       │       └── Command
       │
       ├── Data-access abstraction?
       │       └── Repository
       │
       ├── Complex state lifecycle?
       │       └── State / Discriminated Union
       │
       └── Type-level problem?
               ├── Reuse → Generics
               ├── Transform → Mapped Types
               ├── Decision → Conditional Types
               ├── Extract → infer
               ├── Property safety → keyof
               ├── Literal safety → as const
               ├── Contract validation → satisfies
               └── Domain safety → Branded Types
    

    Considérez-le comme un point de départ pour la discussion ; le principe « commencer par le simple » reste valable à tous les niveaux.

    Que apprendre en premier

    Pour les ingénieurs frontend qui se préparent à des postes de niveau senior ou de chef de projet, mémoriser chaque schéma du Gang of Four est une mauvaise utilisation du temps. Une séquence pratique :

    Les éléments essentiels :

    Discriminated Unions
    Generics
    Type Guards
    keyof
    Mapped Types
    Utility Types
    Composition
    Strategy
    Repository
    Factory
    

    Fortement recommandé ensuite :

    Result Type
    Branded Types
    Conditional Types
    infer
    Exhaustive Checking
    Dependency Injection
    Adapter
    Facade
    Observer
    Decorator
    

    Vaut la peine de le comprendre conceptuellement :

    Builder
    Command
    State
    Abstract Factory
    Singleton
    

    Le travail quotidien sur l’interface utilisateur repose beaucoup plus sur cette combinaison :

    Generics
    +
    Discriminated Unions
    +
    Composition
    +
    Strategy
    +
    Repository
    

    meilleur que celui de n’importe quel manuel sur les usines abstraites.

    Comment le jugement évolue avec l’expérience

    Débutants, les ingénieurs se demandent : « Quel motif dois-je utiliser ici ? » Avec de l’expérience dans les systèmes complexes, la question devient : « Quelle est la plus petite abstraction permettant de résoudre ce problème sans rendre le système plus difficile à comprendre ? » Le processus basé d’abord sur les motifs se présente comme suit :

    Problem
      ↓
    Pattern
      ↓
    More classes
      ↓
    More abstractions
    

    Le flux de travail axé sur le problème se présente comme suit :

    Problem
      ↓
    Understand volatility
      ↓
    Identify boundary
      ↓
    Start simple
      ↓
    Introduce abstraction only where repetition/change justifies it
    

    De bons schémas émergent lorsque les contraintes de l’architecture deviennent claires ; ils ne sont pas imposés à l’avance.

    Questions d’entretien visant à évaluer une véritable compréhension

    « Qu’est-ce que le pattern Factory ? » teste la mémorisation. Ceux-ci évaluent le jugement.

    Architecture :

    1. Face à un composant React de 2 000 lignes, comment choisissez-vous l’abstraction à introduire ?
    2. Quand refuseriez-vous un pattern qui semble approprié ?
    3. Comment savoir qu’une abstraction est prématurée ?
    4. Quand la composition est-elle préférable à l’héritage ?
    5. Comment empêcher qu’un Singleton ne devienne un état mutable global ?

    Principes fondamentaux de TypeScript :

    1. Comment modéliser une requête avec des états de chargement, de succès, d’erreur et de tentative de réessai ?
    2. Comment empêcher les combinaisons invalides de props React ?
    3. En quoi unknown, any et never diffèrent-ils ?
    4. Quand choisir type plutôt que interface, et inversement ?
    5. Comment keyof interagit-il avec les génériques ?

    TypeScript avancé :

    1. Quel est un cas d’usage concret pour les types conditionnels ?
    2. Que fait infer ?
    3. Qu’est-ce qu’un type mappé ?
    4. Qu’est-ce que les types conditionnels distributifs ?
    5. Quand les types marqués sont-ils utiles ?
    6. Quel problème satisfies résout-il ?
    7. Comment as const influence-t-il l’inférence ?

    Conception dans le monde réel :

    1. Concevoir un bus d’événements sûr en termes de types.
    2. Concevoir un client API sûr en termes de types.
    3. Concevoir un système de permissions utilisant TypeScript.
    4. Concevoir une abstraction de paiement prenant en charge Stripe, PayPal et un troisième fournisseur.
    5. Comment ajouter une couche Repository à une application React existante sans la réécrire ?
    6. Comment gérer des événements WebSocket avec des charges utiles différentes ?
    7. Comment empêcher que un ProductId soit transmis là où un UserId est requis ?

    Une question révélatrice : « Décrivez un moment où vous avez délibérément choisi de ne pas utiliser un schéma de conception. » Une réponse pertinente explique le compromis : avec une seule implémentation et aucune modification probable à anticiper, l’indirection n’aurait pas réduit la complexité, de sorte que le code est resté simple jusqu’à ce qu’un autre cas d’usage le justifie.

    Un dernier modèle mental

    Partez du problème métier, identifiez où réside la complexité, séparez les changements comportementaux des changements structurels, puis laissez la sécurité de type renforcer le résultat.

                     BUSINESS PROBLEM
                            │
                            ▼
                    Identify Complexity
                            │
                            ▼
                 What is likely to change?
                            │
                 ┌──────────┴──────────┐
                 ▼                     ▼
              Behavior              Structure
                 │                     │
            Strategy/State       Adapter/Facade
                 │                     │
                 └──────────┬──────────┘
                            ▼
                       Type Safety
                            │
            ┌───────────────┼────────────────┐
            ▼               ▼                ▼
         Generics        Unions           Utilities
            │               │                │
            ▼               ▼                ▼
         keyof          Result Type      Mapped Types
         infer          State Model      Conditional
         satisfies      Exhaustive       Record
            │               │                │
            └───────────────┼────────────────┘
                            ▼
                     SIMPLEER CODE
    

    Les modèles en tant que vocabulaire partagé

    Les modèles fonctionnent le mieux en tant que vocabulaire partagé : « ceci est un adaptateur », « ces comportements sont interchangeables », « ces états appartiennent à une union », « marquez ces identifiants », et, ce qui est le plus important, « cette abstraction n’a pas encore justifié sa complexité ». Une bonne implémentation en TypeScript se juge selon l’existence de ces propriétés, et non selon l’ingéniosité de ses types :

    Invalid states
         ↓
    become difficult to represent
    
    Changing implementations
         ↓
    don't break consumers
    Business rules
         ↓
    are visible in the types
    Shared behavior
         ↓
    is reusable without duplication
    Complexity
         ↓
    is isolated behind clear boundaries
    
    • Choisissez les schémas en fonction de la complexité qu’ils éliminent, et non en fonction de leur familiarité.
    • Préférez les unions, les types Result et les vérifications exhaustives aux champs optionnels et à l’espoir.
    • Vérifiez les données aux limites du temps d’exécution ; les types seuls ne vous protègent pas à cet endroit.
    • Optez pour l’abstraction la plus simple capable de gérer les changements que vous attendez réellement.

    Lectures complémentaires

  • Modèles de conception React : de l’OOP classique aux hooks modernes : Explique comment des modèles logiciels classiques tels que Singleton, Factory et Observer s’appliquent dans React, ainsi que des modèles spécifiques à React comme les HOC, les hooks et les composants composés.