Chaque client API a une state machine cachée à l’intérieur. Handshake d’abord. Authentification ensuite. Envoi de données troisième. Fermeture en dernier. Enfreignez cet ordre et vous obtenez des erreurs à l’exécution, des serveurs déconcertés, ou pire, une corruption silencieuse des données.

La plupart des équipes encodent ces règles avec des vérifications à l’exécution. if (!connected) throw new Error(...). Ça fonctionne jusqu’à ce que quelqu’un oublie la vérification, ou qu’un refactor introduise un nouveau chemin de code qui l’ignore. Au moment où vous le remarquez, le bug est en production.

Le système de types de TypeScript peut éliminer une classe entière de ces bugs. Non pas avec des règles lint astucieuses, mais en rendant les états illégaux littéralement irréprésentables.

Votre client API a une state machine cachée

Les session types sont une technique de système de types pour encoder la séquence valide d’opérations dans un protocole. Au lieu de suivre l’état avec un champ de type string à l’exécution, vous le suivez dans le paramètre de type. Un Channel<'idle'> ne peut appeler que connect(). Un Channel<'connected'> ne peut appeler que send() et close(). Le compilateur rejette tout le reste.

Ce n’est pas académique. Si vous avez déjà utilisé une transaction de base de données, une connexion WebSocket, ou un flux OAuth, vous avez manuellement imposé un protocole de session. Les session types déplacent simplement cette enforcement de l’exécution à la compilation.

Les types fantômes rendent les transitions illégales irréprésentables

Voici une state machine typique en TypeScript :

class Channel {
  private state: 'idle' | 'connected' | 'closed' = 'idle';

  connect() {
    if (this.state !== 'idle') throw new Error('Already connected');
    this.state = 'connected';
  }

  send(msg: string) {
    if (this.state !== 'connected') throw new Error('Not connected');
    // ...
  }

  close() {
    this.state = 'closed';
  }
}

C’est bien jusqu’à ce que ça ne le soit plus. La méthode send() lance une exception à l’exécution si vous l’appelez au mauvais moment. Les tests pourraient le détecter. Peut-être pas. Un refactor pourrait introduire un nouvel appel à send() après close() que personne ne remarque. Le compilateur n’a pas d’avis.

TypeScript utilise le typage structurel. Deux classes avec la même forme sont interchangeables, même si leurs paramètres de type diffèrent. Pour rendre Channel<'idle'> et Channel<'connected'> incompatibles, le paramètre de type doit apparaître dans la structure elle-même.

Le pattern standard est un champ fantôme privé :

class Channel<State extends string> {
  private __state!: State;
  private constructor() {}

  static create(): Channel<'idle'> {
    return new Channel();
  }

  connect(this: Channel<'idle'>): Channel<'connected'> {
    return new Channel();
  }

  send(this: Channel<'connected'>, msg: string): Channel<'connected'> {
    console.log(msg);
    return new Channel();
  }

  close(this: Channel<'connected'>): Channel<'closed'> {
    return new Channel();
  }
}

Le champ __state ne reçoit jamais de valeur. Il existe uniquement pour lier le paramètre de type à la structure de la classe. Parce qu’il est privé, le code externe ne peut pas fabriquer un Channel<'connected'>. Parce qu’il a le type State, Channel<'idle'> et Channel<'connected'> sont des types structurellement différents.

Le paramètre this sur chaque méthode restreint quels états peuvent l’appeler. TypeScript vérifie la compatibilité de this au site d’appel, pas seulement à l’intérieur du corps de la méthode.

Maintenant, les appels illégaux sont des erreurs de compilation :

const ch = Channel.create();
ch.send('hello'); // Error: 'Channel<"idle">' is not assignable to 'Channel<"connected">'

C’est la partie qui piège les gens. Le paramètre this ressemble à une annotation à l’exécution. Ce n’en est pas une. TypeScript l’utilise purement pour le type-checking du récepteur. Si le type du récepteur ne correspond pas, le compilateur rejette l’appel entièrement.

Construire un protocole de téléchargement de fichiers sûr au niveau des types

Le Channel jouet montre le pattern. Voici quelque chose de plus proche d’un vrai protocole : un client de téléchargement de fichiers avec authentification et vérification de checksum.

type UploadState = 'idle' | 'ready' | 'uploading' | 'verifying' | 'done';

class Uploader<State extends UploadState> {
  private __state!: State;
  private file: string;

  private constructor(file: string) {
    this.file = file;
  }

  static forFile(file: string): Uploader<'idle'> {
    return new Uploader(file);
  }

  authenticate(
    this: Uploader<'idle'>,
    token: string
  ): Uploader<'ready'> {
    // verify token...
    return new Uploader(this.file);
  }

  uploadChunk(
    this: Uploader<'ready' | 'uploading'>,
    data: Uint8Array
  ): Uploader<'uploading'> {
    // stream bytes...
    return new Uploader(this.file);
  }

  finalize(
    this: Uploader<'uploading'>,
    checksum: string
  ): Uploader<'verifying'> {
    // start verification...
    return new Uploader(this.file);
  }

  verify(
    this: Uploader<'verifying'>,
    serverChecksum: string
  ): Uploader<'done'> {
    // compare checksums...
    return new Uploader(this.file);
  }
}

Remarquez que uploadChunk accepte Uploader<'ready' | 'uploading'> comme type this. Les union types vous permettent d’exprimer des méthodes qui sont valides à partir de plusieurs états. La méthode retourne Uploader<'uploading'>, donc après le premier appel vous êtes verrouillés dans l’état de téléchargement jusqu’à la finalisation.

const chunk1 = new Uint8Array([0x01, 0x02]);
const chunk2 = new Uint8Array([0x03, 0x04]);

const upload = Uploader.forFile('report.pdf')
  .authenticate('token-123')
  .uploadChunk(chunk1)
  .uploadChunk(chunk2)
  .finalize('abc123')
  .verify('abc123');

// upload is now Uploader<'done'>

Essayez d’appeler authenticate() après uploadChunk(), ou uploadChunk() après verify(), et TypeScript signale une erreur immédiatement.

Où le pattern devient délicat

Le pattern n’est pas gratuit. Le point le plus problématique est que chaque transition d’état construit un nouvel objet. Dans nos exemples, nous créons un nouvel Uploader même quand l’état sous-jacent n’a pas changé, comme uploadChunk retournant Uploader<'uploading'> à partir de Uploader<'uploading'>. Pour des objets complexes avec beaucoup d’état, c’est du gaspillage.

Vous pouvez optimiser en passant l’état au lieu de reconstruire, mais les types deviennent plus complexes. Un deuxième problème : les messages d’erreur de TypeScript pour les incompatibilités de paramètres this sont cryptiques. « The ‘this’ context of type ‘X’ is not assignable to method’s ‘this’ of type ‘Y’ » est exact, mais pas immédiatement évident pour quelqu’un qui lit le code pour la première fois. Un bon nommage et des commentaires aident, mais l’expérience développeur n’est pas parfaite.

Troisièmement, ce pattern ne se compose pas bien avec le code asynchrone. Un await au milieu d’une chaîne casse l’interface fluide, et vous devez stocker le résultat typé intermédiaire dans une variable. Ce n’est pas rédhibitoire, mais cela signifie que le pattern fonctionne mieux pour des protocoles synchrones ou asynchrones soigneusement structurés.

Les tagged unions sont le compromis pragmatique

Si le pattern de types fantômes complet semble lourd, les tagged unions avec des instructions switch exhaustives sont une alternative pragmatique. Vous obtenez toujours la sécurité à la compilation, mais au niveau de la valeur plutôt qu’au niveau du type :

type UploadState =
  | { tag: 'idle'; file: string }
  | { tag: 'ready'; file: string; token: string }
  | { tag: 'uploading'; file: string; sent: number }
  | { tag: 'done'; file: string };

function authenticate(
  state: Extract<UploadState, { tag: 'idle' }>,
  token: string
): UploadState {
  return { tag: 'ready', file: state.file, token };
}

C’est plus idiomatique en TypeScript et plus facile à maintenir pour la plupart des équipes. Le compromis est que vous ne pouvez pas empêcher quelqu’un de passer la mauvaise variante d’état à une fonction au niveau du type sans les mêmes astuces de paramètres this. Le compilateur le détecte à l’intérieur de la fonction, mais le site d’appel n’est pas restreint.

Optez pour les types fantômes quand les violations causent de vrais dommages

Utilisez les types fantômes quand le protocole est assez complexe pour que le violer cause de vrais dommages, et quand la surface de l’API est assez petite pour que vous contrôliez chaque transition. Les pilotes de base de données, les implémentations de protocoles réseau, et les SDKs stateful sont de bons candidats.

Ne les utilisez pas pour des APIs CRUD simples ou quoi que ce soit où la complexité de type supplémentaire coûte plus cher que la vérification occasionnelle à l’exécution.

La prochaine fois que vous écrivez if (state !== 'connected') throw new Error(...), demandez-vous si cette vérification appartient à votre code ou à vos types. Les paramètres this et les champs fantômes de TypeScript vous donnent un moyen de pousser l’enforcement du protocole en amont. Les bugs que vous attraperez ne seront pas les dramatiques. Ce seront les erreurs silencieuses qui passent à travers la revue de code et n’apparaissent que quand un client rencontre un cas limite que vous n’avez pas testé.

Ce sont exactement les bugs qu’il vaut la peine d’éliminer.