Langage des exemples de code

Arrête de vérifier tes états, laisse le compilateur les interdire

Vérifier ses états, c'est déjà avoir perdu


Ces derniers temps, j’ai animé plusieurs dojos de code avec mon équipe. On a pris le Tennis Kata (l’exercice qu’on croit connaître) et on y est revenus séance après séance, en changeant de contrainte à chaque fois.

Le kata a l’air trivial. Quatre points, un deuce, un avantage, un jeu gagné. On l’écrit en vingt minutes. C’est précisément ce qui en fait un bon terrain : la solution évidente marche, passe les tests, et cache pourtant tout ce qui suit.

Ce que ces itérations ont fait remonter m’a confirmé une conviction que je traîne depuis un moment : la machine à états est sous-cotée dans les applications de gestion. On y modélise des devis, des commandes, des dossiers, des contrats, tous des objets qui passent leur vie à changer d’état. Et on les représente presque toujours de la même façon : un champ statut, et des tests sur ce champ dispersés dans toute la codebase.

Le tennis, lui, tient sur un écran. On y voit donc en vingt minutes ce qu’un back-office met deux ans à révéler.

Et il y a mieux. Séance après séance, l’équipe est allée plus loin que la machine à états : quand chaque état s’est mis à porter ses propres données, une classe par état s’est imposée d’elle-même. Couplées, les deux approches font disparaître des familles entières de bugs : pas les corriger, les rendre inexprimables.

Cet article, c’est ce que j’ai observé pendant ces dojos, condensé. On va écrire le Tennis Kata trois fois.

Les règles, pour fixer le vocabulaire

  • Les points se comptent Love, Fifteen, Thirty, Forty.
  • À égalité à Forty, on est à Deuce.
  • Depuis Deuce, gagner un point donne l’Advantage. Le perdre ramène à Deuce.
  • Depuis l’Advantage, gagner à nouveau donne le jeu.
  • À Forty contre moins, gagner un point donne le jeu.

Premier jet : tout vérifier dans une méthode

L’implémentation qui vient naturellement : deux compteurs, et une méthode qui traduit ces compteurs en libellé.

La solution évidente
class TennisGame {
  private p1 = 0;
  private p2 = 0;

  pointWonBy(player: 'player1' | 'player2'): void {
    if (player === 'player1') this.p1++;
    else this.p2++;
  }

  score(): string {
    const names = ['Love', 'Fifteen', 'Thirty', 'Forty'];

    if (this.p1 === this.p2) {
      return this.p1 >= 3 ? 'Deuce' : `${names[this.p1]}-All`;
    }

    if (this.p1 >= 4 || this.p2 >= 4) {
      const diff = this.p1 - this.p2;
      if (diff === 1) return 'Advantage player1';
      if (diff === -1) return 'Advantage player2';
      return diff > 0 ? 'Game player1' : 'Game player2';
    }

    return `${names[this.p1]}-${names[this.p2]}`;
  }
}
public enum Player { Player1, Player2 }

public sealed class TennisGame
{
    private static readonly string[] Names = ["Love", "Fifteen", "Thirty", "Forty"];

    private int _p1;
    private int _p2;

    public void PointWonBy(Player player)
    {
        if (player == Player.Player1) _p1++;
        else _p2++;
    }

    public string Score()
    {
        if (_p1 == _p2)
            return _p1 >= 3 ? "Deuce" : $"{Names[_p1]}-All";

        if (_p1 >= 4 || _p2 >= 4)
        {
            var diff = _p1 - _p2;
            if (diff == 1) return "Advantage player1";
            if (diff == -1) return "Advantage player2";
            return diff > 0 ? "Game player1" : "Game player2";
        }

        return $"{Names[_p1]}-{Names[_p2]}";
    }
}

Ça marche. Les tests passent. C’est la solution que rendent 90 % des candidats, et honnêtement, sur un kata de vingt minutes, elle est défendable.

Regarde quand même ce que fait score() : à chaque appel, elle redéduit dans quel état on se trouve. Égalité ? Au-delà de quatre ? Quel écart ? L’état du jeu n’est écrit nulle part ; il est recalculé à la volée depuis deux entiers.

Et deux entiers, ça permet beaucoup de choses.

Ce que le modèle autorise
const game = new TennisGame();
for (let i = 0; i < 47; i++) game.pointWonBy('player1');

game.score(); // 'Game player1'
var game = new TennisGame();
for (var i = 0; i < 47; i++) game.PointWonBy(Player.Player1);

game.Score(); // "Game player1"

Quarante-sept points gagnés d’affilée. Le jeu était fini depuis le quatrième. pointWonBy a continué d’incrémenter sans broncher, et score() répond quand même quelque chose de plausible.

Le modèle ment sur deux plans :

  • Il accepte des points après la fin du jeu. Aucune méthode ne refuse jamais rien.
  • Il représente des scores impossibles. p1 = 47 est un number parfaitement valide. p1 = -3 aussi.

Le problème n’est pas cette boucle. Le problème, c’est qu’elle compile.

Deuxième jet : la machine à états

Le premier réflexe (et c’est un bon réflexe) est de rendre l’état explicite au lieu de le redéduire. Un jeu de tennis a quatre états : on joue les points, on est à Deuce, quelqu’un a l’Advantage, ou le jeu est gagné.

Les états et leurs enchaînements
type GameState = 'points' | 'deuce' | 'advantage' | 'game';

const TRANSITIONS: Record<GameState, readonly GameState[]> = {
  points:    ['points', 'deuce', 'game'],
  deuce:     ['advantage'],
  advantage: ['deuce', 'game'],
  game:      [],
};
public enum GameState { Points, Deuce, Advantage, Game }

private static readonly Dictionary<GameState, GameState[]> Transitions = new()
{
    [GameState.Points]    = [GameState.Points, GameState.Deuce, GameState.Game],
    [GameState.Deuce]     = [GameState.Advantage],
    [GameState.Advantage] = [GameState.Deuce, GameState.Game],
    [GameState.Game]      = [],
};

Cette table se lit comme un diagramme, et elle dit déjà quelque chose de fort : game est terminal. Aucune sortie. Le jeu fini ne mène nulle part.

Le reste de la classe suit :

L'état devient un champ
class TennisGame {
  private state: GameState = 'points';
  private p1 = 0;
  private p2 = 0;
  private advantaged?: Player;
  private winner?: Player;

  pointWonBy(player: Player): void {
    if (this.state === 'game') {
      throw new Error('Le jeu est terminé');
    }
    // ... calcule le nouvel état, met à jour les champs concernés
  }
}
public sealed class TennisGame
{
    private GameState _state = GameState.Points;
    private int _p1;
    private int _p2;
    private Player? _advantaged;
    private Player? _winner;

    public void PointWonBy(Player player)
    {
        if (_state == GameState.Game)
            throw new InvalidOperationException("Le jeu est terminé");

        // ... calcule le nouvel état, met à jour les champs concernés
    }
}

Ce que tu viens de gagner n’est pas anodin :

  • Le jeu refuse les points après la fin. Les 47 points de tout à l’heure lèvent maintenant une exception au cinquième. C’est le bug le plus visible du premier jet, corrigé.
  • Les règles d’enchaînement sont à un seul endroit. La table est la spécification, et elle se teste en quelques lignes.
  • score() cesse de deviner. Elle devient un aiguillage sur state au lieu d’une cascade de comparaisons.

Si tu en es là, tu as sérieusement amélioré la situation. Beaucoup de codebases ne vont jamais plus loin, et se portent très bien.

Ce que la machine à états ne règle pas

Reviens sur les champs de la classe. Il y en a maintenant cinq, et regarde lesquels ont un sens dans quel état :

Champ A du sens quand…
p1, p2 on joue les points ; plus rien à dire une fois à Deuce
advantaged et seulement si on est à Advantage
winner et seulement si le jeu est gagné
state toujours

Deux champs optionnels, deux compteurs qui ne veulent plus rien dire passé Deuce. Le compilateur, lui, autorise toutes les combinaisons : un état deuce avec un winner renseigné, un état game sans winner, un advantaged qui traîne d’un avantage perdu il y a trois points.

Et comme le type dit oui, ton code doit dire non. Partout :

L'aveu
score(): string {
  switch (this.state) {
    case 'game':
      if (this.winner === undefined) {
        throw new Error('Jeu gagné sans vainqueur ?!'); // le "?!" en dit long
      }
      return `Game ${this.winner}`;

    case 'advantage':
      if (this.advantaged === undefined) {
        throw new Error('Advantage sans joueur ?!');
      }
      return `Advantage ${this.advantaged}`;

    // ...
  }
}
public string Score() => _state switch
{
    GameState.Game => _winner is null
        ? throw new InvalidOperationException("Jeu gagné sans vainqueur ?!") // le "?!" en dit long
        : $"Game {_winner}",

    GameState.Advantage => _advantaged is null
        ? throw new InvalidOperationException("Advantage sans joueur ?!")
        : $"Advantage {_advantaged}",

    // ...
};

Ces throw sont des aveux. Tu sais qu’un jeu gagné a un vainqueur. Le compilateur, lui, ne le sait pas, alors tu écris une garde défensive, tu la testes, tu la maintiens, et tu la répètes partout où un champ optionnel est en réalité obligatoire dans cet état-là.

La machine à états a validé les transitions. Elle n’a rien dit sur les données.

Le déclic : Deuce ne porte aucune donnée

Regarde ce que chaque état sait réellement, et lui seul :

État Données propres
Points les deux compteurs
Deuce rien du tout
Advantage quel joueur a l’avantage
Game quel joueur a gagné

Cette ligne « rien du tout » est le cœur du sujet. Deuce, c’est Deuce. Il n’y a rien à savoir de plus. Pourtant, dans la version machine à états, un jeu à Deuce trimballe quand même deux compteurs, un advantaged et un winner : quatre champs pour un état qui n’en a besoin d’aucun.

Et ce n’est pas qu’une affaire de données. Les opérations diffèrent aussi :

  • Depuis Points, gagner un point mène à Points, à Deuce, ou au jeu.
  • Depuis Deuce, gagner un point mène toujours à Advantage. Jamais ailleurs.
  • Depuis Advantage, on retourne à Deuce ou on gagne.
  • Depuis Game, on ne fait rien. C’est fini.

Quand les données et les opérations diffèrent d’un état à l’autre, tu n’as pas un objet avec un champ state. Tu as une famille de types. C’est le Typestate.

Troisième jet : une classe par état

L’idée tient en une phrase : l’état devient le type. Les transitions ne modifient pas un champ, elles retournent une instance d’un autre type.

Commençons par rendre un score impossible… impossible :

Fini les entiers
type Player = 'player1' | 'player2';
type PointCount = 'Love' | 'Fifteen' | 'Thirty' | 'Forty';

// Forty est exclu des CLÉS : depuis Forty on ne progresse pas, on gagne.
// Le compilateur refusera donc NEXT['Forty'].
const NEXT: Record<Exclude<PointCount, 'Forty'>, PointCount> = {
  Love: 'Fifteen',
  Fifteen: 'Thirty',
  Thirty: 'Forty',
};
public enum Player { Player1, Player2 }
public enum PointCount { Love, Fifteen, Thirty, Forty }

// C# n'a pas de type union : la base commune se déclare d'emblée,
// et les quatre états en hériteront.
public abstract class GameState
{
    public abstract string Score { get; }
}

private static PointCount Next(PointCount p) => p switch
{
    PointCount.Love    => PointCount.Fifteen,
    PointCount.Fifteen => PointCount.Thirty,
    PointCount.Thirty  => PointCount.Forty,
    // Depuis Forty on ne progresse pas : on gagne. L'appeler est un bug.
    _ => throw new ArgumentOutOfRangeException(nameof(p)),
};

p1 = 47 n’est plus un bug à attraper : c’est une phrase que le langage ne sait pas prononcer.

Points : deux compteurs
class Points {
  // Constructeur PRIVÉ : personne ne peut fabriquer un Forty-Forty depuis
  // l'extérieur. Cet état-là s'appelle Deuce, et il a sa propre classe.
  private constructor(
    private readonly p1: PointCount,
    private readonly p2: PointCount,
  ) {}

  static start(): Points {
    return new Points('Love', 'Love');
  }

  pointWonBy(scorer: Player): Points | Deuce | Game {
    const mine = scorer === 'player1' ? this.p1 : this.p2;
    const theirs = scorer === 'player1' ? this.p2 : this.p1;

    if (mine === 'Forty') return new Game(scorer);

    const promoted = NEXT[mine];
    if (promoted === 'Forty' && theirs === 'Forty') return new Deuce();

    return scorer === 'player1'
      ? new Points(promoted, this.p2)
      : new Points(this.p1, promoted);
  }

  get score(): string {
    return this.p1 === this.p2 ? `${this.p1}-All` : `${this.p1}-${this.p2}`;
  }
}
public sealed class Points : GameState
{
    private readonly PointCount _p1;
    private readonly PointCount _p2;

    // Constructeur PRIVÉ : personne ne peut fabriquer un Forty-Forty depuis
    // l'extérieur. Cet état-là s'appelle Deuce, et il a sa propre classe.
    private Points(PointCount p1, PointCount p2)
    {
        _p1 = p1;
        _p2 = p2;
    }

    public static Points Start() => new(PointCount.Love, PointCount.Love);

    // GameState et non « Points | Deuce | Game » : C# ne sait pas dire mieux.
    public GameState PointWonBy(Player player)
    {
        var mine   = player == Player.Player1 ? _p1 : _p2;
        var theirs = player == Player.Player1 ? _p2 : _p1;

        if (mine == PointCount.Forty) return new Game(player);

        var promoted = Next(mine);
        if (promoted == PointCount.Forty && theirs == PointCount.Forty) return new Deuce();

        return player == Player.Player1
            ? new Points(promoted, _p2)
            : new Points(_p1, promoted);
    }

    public override string Score => _p1 == _p2 ? $"{_p1}-All" : $"{_p1}-{_p2}";
}
Deuce : aucun champ
class Deuce {
  // Aucune donnée. Deuce, c'est Deuce.
  // `declare` n'émet aucun JS : ce champ n'existe que pour le compilateur,
  // afin que Deuce ne soit pas imitable par n'importe quel objet de même
  // forme. On y revient dans « Le prix à payer ».
  declare private readonly __brand: never;

  pointWonBy(scorer: Player): Advantage {
    return new Advantage(scorer);
  }

  get score(): string {
    return 'Deuce';
  }
}
public sealed class Deuce : GameState
{
    // Aucune donnée. Deuce, c'est Deuce.

    public Advantage PointWonBy(Player player) => new(player);

    public override string Score => "Deuce";
}

Une classe sans champ, dont la seule transition possible retourne un seul type. Compare avec la version précédente, où le même état traînait quatre champs dont aucun ne le concernait.

Advantage : un joueur
class Advantage {
  constructor(private readonly player: Player) {}

  pointWonBy(scorer: Player): Deuce | Game {
    return scorer === this.player ? new Game(scorer) : new Deuce();
  }

  get score(): string {
    return `Advantage ${this.player}`;
  }
}
public sealed class Advantage(Player player) : GameState
{
    private readonly Player _player = player;

    public GameState PointWonBy(Player scorer)
        => scorer == _player ? new Game(scorer) : new Deuce();

    public override string Score => $"Advantage {_player}";
}
Game : état terminal
class Game {
  constructor(private readonly winner: Player) {}

  // Pas de pointWonBy. Le jeu est fini. Volontairement.

  get score(): string {
    return `Game ${this.winner}`;
  }
}
public sealed class Game(Player winner) : GameState
{
    private readonly Player _winner = winner;

    // Pas de PointWonBy. Le jeu est fini. Volontairement.

    public override string Score => $"Game {_winner}";
}

Relis les deux score de la fin. Plus de garde. Plus de throw. Plus de test pour couvrir une branche qui ne peut pas arriver. Le vainqueur est un joueur, point, parce qu’un Game n’existe pas sans vainqueur.

Et les 47 points du début ?

Le bug devient une erreur de compilation
const game = new Game('player1');

game.pointWonBy('player1');
//   ~~~~~~~~~~ Property 'pointWonBy' does not exist on type 'Game'.
var game = new Game(Player.Player1);

game.PointWonBy(Player.Player1);
//   ~~~~~~~~~~ CS1061 : 'Game' ne contient pas de définition pour 'PointWonBy'

Ce n’est plus une exception à l’exécution, découverte par un utilisateur un vendredi soir. C’est un soulignement rouge dans ton éditeur, avant même d’avoir sauvegardé.

La tension : fluidité contre garantie

Puisque chaque transition retourne l’état suivant, on aimerait enchaîner les appels, et une partie se lirait comme une feuille de match :

Ce qu'on aimerait écrire
const game = Points.start()
  .pointWonBy('player2')
  .pointWonBy('player2');
//   ~~~~~~~~~~
// TS2339: Property 'pointWonBy' does not exist on type 'Deuce | Game | Points'.
//         Property 'pointWonBy' does not exist on type 'Game'.
var game = Points.Start()
    .PointWonBy(Player.Player2)
    .PointWonBy(Player.Player2);
//   ~~~~~~~~~~
// CS1061 : 'GameState' ne contient pas de définition pour 'PointWonBy'

Ça ne compile pas. Et c’est logique : dès le premier point, on ne tient plus un Points mais « l’un des trois états possibles » (dont Game, qui n’a justement pas de méthode).

Cette gêne mérite qu’on s’y arrête, parce qu’elle explique une erreur qu’on voit souvent. Le réflexe naturel, pour retrouver le chaînage, c’est de donner à l’état terminal la méthode qui lui manque :

class Game {
  pointWonBy(): never {
    throw new Error('Le jeu est terminé'); // ⚠️ on vient de tout perdre
  }
}

Le chaînage remarche. Les tests redeviennent élégants. Et la garantie a disparu : marquer un point sur un jeu gagné compile de nouveau, et n’échoue plus qu’à l’exécution. On a réintroduit exactement le bug du premier jet, après avoir construit quatre classes pour l’éliminer.

L’union est ce qui pousse à cette faute. Manipuler « un état quelconque » exige que tous les membres partagent la méthode, donc que le terminal l’ait aussi.

La sortie propre, c’est d’aiguiller une fois, à un seul endroit :

Un seul point d'aiguillage
type GameState = Points | Deuce | Advantage | Game;

function play(points: readonly Player[]): GameState {
  let state: GameState = Points.start();

  for (const player of points) {
    if (state instanceof Game) return state; // le jeu était déjà fini
    state = state.pointWonBy(player);
  }

  return state;
}
public static GameState Play(IEnumerable<Player> points)
{
    GameState state = Points.Start();

    foreach (var player in points)
    {
        // Obligatoire ici : GameState n'expose pas PointWonBy,
        // justement parce que Game ne doit pas l'avoir.
        state = state switch
        {
            Points p    => p.PointWonBy(player),
            Deuce d     => d.PointWonBy(player),
            Advantage a => a.PointWonBy(player),
            Game        => state, // le jeu était déjà fini
            _           => throw new InvalidOperationException(),
        };
    }

    return state;
}

Et les tests retrouvent leur lisibilité, sans rien céder :

Six points partagés, un deuce
it('atteint Deuce après six points partagés', () => {
  const game = play([
    'player2', 'player2', 'player2',
    'player1', 'player1', 'player1',
  ]);

  expect(game.score).toBe('Deuce');
});
[Fact]
public void Atteint_Deuce_apres_six_points_partages()
{
    var game = Play([
        Player.Player2, Player.Player2, Player.Player2,
        Player.Player1, Player.Player1, Player.Player1,
    ]);

    Assert.Equal("Deuce", game.Score);
}

Le compromis est clair : le typestate coûte la fluidité du chaînage, et paie en erreurs de compilation. Si tu tiens absolument au chaînage, tu devras redonner une méthode à l’état terminal, et renoncer à ce que le pattern t’apportait.

Ce qui disparaît

C’est le meilleur indicateur qu’un pattern travaille pour toi : compte ce qu’il supprime.

La table de transitions disparaît. Elle n’a plus besoin d’exister : elle est encodée dans les signatures. Deuce.pointWonBy(): Advantage est la ligne deuce → advantage. Le diagramme d’états, tu le lis dans les types de retour :

Signature Ce qu’elle dit
Points.pointWonBy(): Points | Deuce | Game trois sorties possibles
Deuce.pointWonBy(): Advantage une seule sortie
Advantage.pointWonBy(): Deuce | Game deux sorties
Game, aucune méthode état terminal

Le champ state disparaît. Il n’y a plus d’état à interroger. Tu ne peux appeler que ce que le type expose.

Les champs optionnels disparaissent. Zéro. Un jeu à Deuce ne transporte plus quatre champs vides ; il ne transporte rien.

Les gardes défensives disparaissent, et avec elles les tests qui les couvraient, et les branches jamais exercées qui polluaient ton rapport de couverture.

C’est le principe que Yaron Minsky a résumé en make illegal states unrepresentable. Et c’est exactement le prolongement de Tell, Don’t Ask : on ne demande plus son état à l’objet pour décider à sa place, on lui envoie un message et il retourne ce qu’il est devenu.

Et ma commande, alors ?

J’ai dit plusieurs fois que ce pattern brille sur les cycles de vie de commande. Objection légitime : une commande accumule des données au fil du processus — lignes, adresse, paiement, numéro de suivi. Instinctivement, on ajoute des champs, on ne change pas de type.

Le typestate ne contredit pas cet instinct : les données voyagent avec les transitions. Ce qui change d’un état à l’autre, ce ne sont pas tant les données que les opérations permises.

La transition emporte les données
class DraftOrder {
  private readonly lines: OrderLine[] = [];

  addItem(line: OrderLine): DraftOrder { /* ... */ }
  removeItem(id: LineId): DraftOrder { /* ... */ }

  confirm(shipping: Address): ConfirmedOrder {
    // Les lignes voyagent avec la transition. Rien ne se perd.
    return new ConfirmedOrder(this.lines, shipping, new Date());
  }
}

class ConfirmedOrder {
  constructor(
    private readonly lines: readonly OrderLine[],
    private readonly shipping: Address,
    private readonly confirmedAt: Date,
  ) {}

  // Pas de addItem. Une commande confirmée ne se modifie plus. Volontairement.

  ship(trackingNumber: string): ShippedOrder {
    return new ShippedOrder(this.lines, this.shipping, trackingNumber);
  }
}
public sealed class DraftOrder
{
    private readonly List<OrderLine> _lines = [];

    public DraftOrder AddItem(OrderLine line) { /* ... */ }
    public DraftOrder RemoveItem(LineId id) { /* ... */ }

    public ConfirmedOrder Confirm(Address shipping)
        // Les lignes voyagent avec la transition. Rien ne se perd.
        => new(_lines, shipping, DateTime.UtcNow);
}

public sealed class ConfirmedOrder
{
    private readonly IReadOnlyList<OrderLine> _lines;
    private readonly Address _shipping;
    private readonly DateTime _confirmedAt;

    public ConfirmedOrder(IReadOnlyList<OrderLine> lines, Address shipping, DateTime confirmedAt)
    {
        _lines = lines;
        _shipping = shipping;
        _confirmedAt = confirmedAt;
    }

    // Pas de AddItem. Une commande confirmée ne se modifie plus. Volontairement.

    public ShippedOrder Ship(string trackingNumber)
        => new(_lines, _shipping, trackingNumber);
}

ConfirmedOrder a plus de données que DraftOrder : les lignes, plus l’adresse, plus la date. L’accumulation que tu ferais avec des champs, la transition la fait en construisant l’état suivant. La différence est ailleurs : addItem a disparu de ConfirmedOrder. Pas désactivé par un if, pas gardé par une exception — absent de la classe.

Compare avec la version champ statut, que tu as forcément déjà croisée :

La version champ statut
addItem(line: OrderLine): void {
  if (this.status !== 'draft') {
    throw new Error('Impossible de modifier une commande confirmée');
  }
  this.lines.push(line);
}
public void AddItem(OrderLine line)
{
    if (_status != OrderStatus.Draft)
        throw new InvalidOperationException("Impossible de modifier une commande confirmée");

    _lines.Add(line);
}

Cette garde, il faut l’écrire dans chaque méthode de modification, la tester, et prier pour que le prochain dev qui ajoute applyDiscount n’oublie pas la sienne. Avec une classe par état, la question ne se pose plus : il ne peut pas appeler une méthode qui n’existe pas, et son IDE le lui dit avant même la revue de code.

Et le signal des champs optionnels vaut aussi ici : trackingNumber n’a de sens qu’expédiée, confirmedAt qu’une fois confirmée. Si ta classe Order porte six champs nullables dont chacun n’est valide que dans certains statuts, ton type ment — exactement comme le jeu de tennis à Deuce qui trimballait quatre champs pour rien.

Le prix à payer

Ce serait malhonnête de s’arrêter là.

Deuce reste imitable, et c’est structurel. TypeScript compare les types par leur forme, pas par leur nom. Un objet littéral de même forme est donc accepté à la place d’une classe, sauf si celle-ci possède un membre privé, qui la rend nominale.

C’est la vraie raison pour laquelle p1, p2, player et winner sont private et pas seulement readonly : personne n’en a besoin de l’extérieur, le score suffit. Et l’encapsulation offre la nominalité en prime.

const p: Points    = { score: 'x', pointWonBy: () => new Deuce() };
// TS2739: missing the following properties from type 'Points': p1, p2
const a: Advantage = { score: 'x', pointWonBy: () => new Deuce() };
// TS2741: Property 'player' is missing
const g: Game      = { score: 'x' };
// TS2741: Property 'winner' is missing

const d: Deuce = { score: 'Deuce', pointWonBy: () => new Advantage('player1') };
// compile. Deuce n'a aucun champ privé à opposer.

Trois classes sur quatre sont protégées gratuitement, par les champs privés qu’elles portaient déjà. Deuce n’a rien à cacher, donc rien à opposer. C’est pour ça, et uniquement pour ça, qu’il porte la ligne suivante :

declare private readonly __brand: never;

Le modificateur declare n’émet aucun JavaScript. À l’exécution, Deuce ne porte toujours réellement aucune donnée ; le champ n’existe que pour rendre la classe nominale, et never garantit que rien ne pourra jamais lui être affecté. C’est le prix à payer pour que la promesse tienne aussi au niveau des types : une ligne, zéro octet.

Attention en revanche au flavoring, la variante permissive de cette technique. Son marqueur est optionnel par construction, donc un objet qui ne le porte pas reste assignable. Il documente l’intention sans la faire respecter. Pour ce qu’on cherche ici, c’est une marque ou rien.

Et note que le constructeur privé ne remplace pas la marque : il contrôle la construction, pas l’assignabilité. Il faut les deux.

C# ne pose aucune de ces questions : ses types sont nominaux par défaut. Cette ligne est un impôt purement TypeScript.

Le point d’aiguillage doit exister, et c’est play, le seul de toute l’implémentation. Il vit à la frontière, là où l’on tient un état dont on ignore la nature. Tout le reste du code travaille sur des types précis.

C’est aussi l’endroit où les deux langages divergent le plus ; bascule les onglets de play pour le voir. TypeScript compose l’union Points | Deuce | Advantage | Game après coup, sans toucher aux classes, et pointWonBy y annonce précisément ses successeurs possibles. C# exige une classe de base déclarée d’avance, et son PointWonBy ne peut retourner que GameState : le compilateur empêche toujours d’appeler PointWonBy sur un Game, ce qui est l’essentiel, mais il ne dit plus vers quels états une transition peut mener. Le typestate fonctionne dans les deux, avec moins de finesse d’un côté.

Il y a plus de classes. Quatre types plutôt qu’un, pour un kata de vingt minutes. Sur un jeu de tennis, c’est discutable. Sur un cycle de vie de commande avec huit états et des données métier lourdes, ça ne l’est plus (on l’a vu plus haut).

L’ancien objet reste accessible. Après const next = deuce.pointWonBy('player1'), la variable deuce existe toujours et reste utilisable. Rien n’empêche de rejouer un point depuis un état périmé.

C’est là que Rust va plus loin. Regarde la signature de la même transition :

struct Deuce;

impl Deuce {
    // `self` sans `&` : la méthode prend POSSESSION de l'objet.
    // Après l'appel, il n'existe plus.
    fn point_won_by(self, player: Player) -> Advantage {
        Advantage { player }
    }
}

let deuce = Deuce;
let adv = deuce.point_won_by(Player::P1); // `deuce` est consommé ici
let oops = deuce.point_won_by(Player::P2);
//         ^^^^^ error[E0382]: use of moved value: `deuce`

Tout tient dans le self sans &. En Rust, passer une valeur sans référence en transfère la propriété : la méthode consomme l’objet, et la variable d’origine cesse d’être utilisable. Rejouer un point depuis deuce après la transition n’est pas un bug à attraper en revue de code, c’est une erreur de compilation. La dernière faille du pattern — l’état périmé qui traîne — se referme.

En TypeScript, en C# ou en Java, rien d’équivalent : le typestate y protège des opérations invalides, pas de la réutilisation d’un état dépassé. C’est une limite à connaître, pas une raison de renoncer.

Alors, lequel choisir ?

Deux ou trois états, aucune donnée propre, des transitions triviales.
Une énumérationN'invente rien.
Beaucoup d'états, les mêmes données partout, des transitions complexes.
Machine à étatsLa table centralise les règles et se lit comme un diagramme.
Les états portent des données différentes et exposent des opérations différentes.
TypestateUne classe par état. Le compilateur devient le gardien.
États et données divergents, et un flux de transitions touffu.
Les deuxTypestate pour les données, table pour orchestrer le flux.

Le signal le plus fiable reste celui-ci : compte tes champs optionnels. Si plusieurs d’entre eux sont en réalité obligatoires, mais seulement dans certains états, ton type ment. Et chaque garde défensive que tu écris pour compenser ce mensonge est du travail que le compilateur aurait pu faire gratuitement.

Le tennis le dit mieux que n’importe quel diagramme : quand un de tes états n’a besoin d’aucune donnée, et qu’il en transporte quatre, c’est que le modèle n’est pas au bon endroit.


Tu veux creuser ces sujets avec ton équipe ? J’interviens en Brown Bag Lunch dans vos locaux, ou par mail à edouard@lacrafterie.tech.