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

> TypeScript n'est souvent utilisé qu'en surface. C'est vrai en particulier pour les Union Types, qui permettent d'empêcher des états incohérents.

- Author: Mathieu Eveillard
- Published: 2026-01-14
- Tags: Artisanat logiciel
- Canonical: https://www.mathieueveillard.com/fr/articles/union-types/
- Translation (en): https://www.mathieueveillard.com/articles/union-types.md

![Et si votre modèle TypeScript empêchait les états incohérents ?](https://www.mathieueveillard.com/articles/2026-01-14-union-types/union-types.webp)

Allez, on reprend l'année en douceur (un 14 janvier…) avec un 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 :

```ts
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](https://www.mathieueveillard.com/fr/articles/typage-nominal) 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](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#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.

```ts
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 :

```ts
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` :

```ts
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 :

```ts
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!_
