Et si votre modèle TypeScript empêchait les états incohérents ?

Allez, on reprend l’année en douceur (un 14 janvier…) avec une sujet de modélisation destiné à montrer que, souvent, l’on n’utilise TypeScript qu’en surface.
Prenons l’exemple d’une tâche, dont le statut peut aller de PENDING à COMPLETED, FAILED ou CANCELLED. Plusieurs attributs sont conditionnés par le statut de la tâche, tel finishedAt, qui n’a de sens qu’aux statuts COMPLETED, FAILED et CANCELLED. Intuitivement, il serait tentant de modéliser la chose via un unique type Task et des attributs optionnels :
type TaskStatus =
"PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | "CANCELLED";
type Task = {
readonly id: string;
readonly description: string;
readonly status: TaskStatus;
readonly scheduledAt?: Date;
readonly startedAt?: Date;
readonly finishedAt?: Date;
readonly failureReason?: string;
};
Avant toute chose, pour ne pas prêter le flanc à la critique, oui on sait faire mieux que id: string.
Le problème de cette première intuition est que le compilateur ne nous contraint que très peu. Il reste ainsi possible de créer une tâche au statut PENDING avec l’attribut finishedAt, ce qui est… dommage.
C’est là que les Union Types de TypeScript entrent en jeu. L’idée est de modéliser individuellement chaque état, puis de définir une tâche comme l’union de ces états possibles.
type AbstractTask<Status extends string, Body> = {
readonly id: string;
readonly status: Status;
readonly description: string;
} & Body;
type PendingTask = AbstractTask<
"PENDING",
{
readonly scheduledAt: Date;
}
>;
type InProgressTask = AbstractTask<
"IN_PROGRESS",
{
readonly startedAt: Date;
}
>;
type CompletedTask = AbstractTask<
"COMPLETED",
{
readonly startedAt: Date;
readonly finishedAt: Date;
}
>;
type FailedTask = AbstractTask<
"FAILED",
{
readonly startedAt: Date;
readonly failedAt: Date;
readonly reason: string;
}
>;
type CancelledTask = AbstractTask<
"CANCELLED",
{
readonly cancelledAt: Date;
readonly reason: string;
}
>;
type Task =
PendingTask | InProgressTask | CompletedTask | FailedTask | CancelledTask;
Le bénéfice est immédiat : seuls des états cohérents peuvent être représentés. Il n’est donc plus possible de créer une tâche au statut PENDING avec un attribut finishedAt, pas plus qu’une tâche au statut COMPLETED sans ce même attribut. Les états incohérents sont devenus irreprésentables grâce au compilateur, véritable gardien des invariants métier. Le statut, quant à lui, n’est plus décoratif, il est réellement utilisé pour discriminer vers le bon type.
Fini également les assertions (task.startedAt ??) et équivalents (task.startedAt ||), voire le forçage du compilateur en bonne et due forme (task.startedAt!), grâce à l’équivalent TypeScript du pattern matching :
const display = (task: Task) => {
switch (task.status) {
case "PENDING":
return "En attente";
case "IN_PROGRESS":
return `Démarrée le ${task.startedAt}`;
case "COMPLETED":
return `Terminée le ${task.finishedAt}`;
case "CANCELLED":
return `Annulée : ${task.reason}`;
case "FAILED":
return `Échec : ${task.reason}`;
}
};
Avant d’aller plus loin, 2 mots sur l’implémentation via AbstractTask :
type AbstractTask<Status extends string, Body> = {
readonly id: string;
readonly status: Status;
readonly description: string;
} & Body;
La création de ce type est une façon parmi d’autres de forcer la présence d’attributs communs à tous les états et de centraliser le changement s’il doit avoir lieu (comme quand, en bon français que je suis, j’écris statut avant de renommer en status).
Voilà. Maintenant, nous pouvons aller encore un cran plus loin (trop loin, peut-être, à voir), au travers d’un peu de composition. Ainsi, chaque statut référence le statut qui le précède :
type PendingTask = {
readonly id: string;
readonly description: string;
readonly scheduledAt: Date;
};
type InProgressTask = {
readonly id: string;
readonly pendingTaskId: string;
readonly startedAt: Date;
};
type CompletedTask = {
readonly id: string;
readonly completedTaskId: string;
readonly finishedAt: Date;
};
// etc.
Cette modélisation traduit explicitement le cycle de vie de la tâche : les changements d’état sont irréversibles, aucun retour en arrière n’est possible.
Bien sûr, cela n’est pas neutre en matière de persistance. Pour un workflow simple, c’est peut-être un peu trop, mais seulement « peut-être », car bien des règles peuvent découler de chaque état. Comme toujours, il faudrait en savoir plus 😁
Ce que vous pouvez en tout cas retenir : les Union Types, véritable life saver!