Mathieu Eveillard

What if your TypeScript model prevented inconsistent states?

What if your TypeScript model prevented inconsistent states?

Alright, let’s ease back into the year (on January 14…) with a modeling exercise designed to show that, often, we only use TypeScript on the surface.

Let’s take the example of a task whose status can range from PENDING to COMPLETED, FAILED, or CANCELLED. Several attributes depend on the task’s status, such as finishedAt, which only makes sense for the statuses COMPLETED, FAILED, and CANCELLED. Intuitively, it would be tempting to model this using a single Task type and optional attributes:

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;
};

First of all, to avoid opening ourselves up to criticism, yes, we know there’s a better way than id: string.

The problem with this initial intuition is that the compiler imposes very few constraints. It’s still possible to create a task with the status PENDING and the attribute finishedAt, which is… unfortunate.

This is where TypeScript’s union types come into play. The idea is to model each state individually, then define a task as the union of these possible states.

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;

The benefit is immediate: only consistent states can be represented. It is therefore no longer possible to create a task with the status PENDING with a finishedAt attribute, nor a task with the status COMPLETED without that same attribute. Inconsistent states have become unrepresentable thanks to the compiler, the true guardian of business invariants. As for the status, it is no longer merely decorative; it is actually used to determine the correct type.

Gone too are assertions (task.startedAt ??) and equivalents (task.startedAt ||), and even formally forcing the compiler (task.startedAt!), thanks to TypeScript’s equivalent of 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}`;
  }
};

Before going any further, a few words about the implementation using AbstractTask:

type AbstractTask<Status extends string, Body> = {
  readonly id: string;
  readonly status: Status;
  readonly description: string;
} & Body;

Creating this type is one way among many to enforce the presence of attributes common to all states and to centralize changes if they need to be made (such as when, as a good French speaker, I write statut before renaming it to status).

There you go. Now we can take it one step further (too far, perhaps—we’ll see) through a bit of composition. Thus, each status references the status that precedes it:

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.

This modeling explicitly reflects the task’s lifecycle: state changes are irreversible; there’s no going back.

Of course, this has implications for persistence. For a simple workflow, it might be a bit much—but only “maybe,” since many rules can stem from each state. As always, we’d need to know more 😁

What you can take away from this, in any case: Union Types are a real lifesaver!

← All posts