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é.
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.
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 = 47est unnumberparfaitement valide.p1 = -3aussi.
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é.
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 :
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 surstateau 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 :
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 :
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.
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}";
}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.
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}";
}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 ?
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 :
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 :
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 :
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.
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 :
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.