The Principal Dev – Masterclass for Tech Leads

The Principal Dev – Masterclass for Tech LeadsNov 27-28

Join

Machinist

Type-driven finite state machines

Describe state machines with types, letting them drive implementation and usage.

Installation

deno add jsr:@machinist/core
pnpm add jsr:@machinist/core
yarn add jsr:@machinist/core

# npm
npx jsr add @machinist/core

# bun
bunx jsr add @machinist/core

Usage

First describe the machine with its states and transitions at the type level, using a discriminated union:

type BaseUser = {
  age: number;
  name: string;
};

type ActiveUser = BaseUser & {
  status: "active";

  lock(reason: string): LockedUser;
};

type LockedUser = BaseUser & {
  status: "locked";
  lockReason: string;

  unlock(): ActiveUser;
  ban(): BannedUser;
};

type BannedUser = BaseUser & {
  status: "banned";
  bannedAt: Date;
};

type User = ActiveUser | LockedUser | BannedUser;

Transitions are methods that return a new state of the machine. Here an active user can only transition to a locked state, while a banned user is in a final state meaning it can't transition to any other state.

Since we're working with a discriminated union we can narrow the type of a user based on its status, and have the compiler only accept valid transitions for this state:

if (user.status === "active") {
  // user has been narrowed, the compiler knows `lock` is available
  user.lock("reason");
}
// else we can't call `lock`

[!IMPORTANT] The instances of the machines are immutable, transitions return new instances and leave the original one unchanged.

const bannedUser = activeUser.lock("reason").ban();
// activeUser !== bannedUser

Do not mutate the state directly, create dedicated transitions instead (can be a self-transition if the type doesn't change).

Implementation

To implement the transitions call the createMachine function with the machine as type argument:

import { createMachine } from "@machinist/core";

const userMachine = createMachine<User>({
  transitions: {
    lock: (user, reason) => ({ ...user, status: "locked", lockReason: reason }),
    unlock: (user) => ({ ...user, status: "active" }),
    ban: (user) => ({ ...user, status: "banned", bannedAt: new Date() }),
  },
});

Transitions take the current state as first parameter, followed by the parameters declared in the types. They return the new state according to the destination type of the transition.

Finally to spawn new instances of the machine call the new method with the initial state:

const activeUser = userMachine.new({
  status: "active",
  name: "Alice",
  age: 25,
});

const lockedUser = userMachine.new({
  status: "locked",
  name: "Bob",
  age: 30,
  lockReason: "reason",
});

Keeping the implementation separate allows the declaration to remain high-level and readable, without drowning the signal in implementation details. It also allows for multiple implementations of the same machine declaration.
You can still jump between the declaration and the implementations with your editor's symbols navigation (Go to Type Definition/Go to Implementation).

Methods

Methods that aren't transitions (that don't transition to a state of the machine) are implemented under methods:

type BannedUser = BaseUser & {
  //...
  daysSinceBan: () => number;
};

const userMachine = createMachine<User>({
  transitions: {
    //...
  },
  methods: {
    daysSinceBan: (user) =>
      (Date.now() - user.bannedAt.getTime()) / (1000 * 60 * 60 * 24),
  },
});

userMachine.new({
  name: "Charlie",
  age: 35,
  status: "banned",
  bannedAt: new Date("2021-01-01"),
}).daysSinceBan(); // 123

That means createMachine is useful beyond just state machines, and can be used as a general implementation target just like classes (the main difference being this replaced by the first parameter of the method).

onTransition callback

With onTransition you can listen to every transition happening in the machine, and run side-effects depending on the previous and new state:

createMachine<User>({
  transitions: {
    //...
  },
  onTransition: (from, to) => {
    console.log(`Transition from ${from.status} to ${to.status}`);
    if (from.status === "locked" && to.status === "active") {
      console.log(
        `User ${to.name} unlocked. Previous reason "${from.lockReason}" does not apply anymore.`,
      );
    }
  },
});

React

@machinist/react exports everything that's in core, plus a useMachine hook.
It takes a machine implementation and an initial state, and returns a reactive instance that will rerender the component on changes.

import { useMachine } from "@machinist/react";
import { userMachine } from "./userMachine";

const Component = () => {
  const user = useMachine(userMachine, initialState);

  return (
    <>
      <div>Name: {user.name}</div>
      {user.status === "locked" && (
        <button onClick={user.unlock}>
          Unlock
        </button>
      )}
      {/* ... */}
    </>
  );
};

It is conceptually similar to useReducer, but with the additional benefits of the compiler checking if the transition is valid for the current state.

To support additional frameworks PRs are welcome!

Type helper

Declaring discriminated unions can be a bit verbose and unwieldy: every member needs to be declared as a separate type, that potentially needs to be exported. In each one of them the key of the discriminant property has to bear the exact same name (e.g. status). Finally they each need to extend the common base type (if any), and also not be omitted from the final union.

The library provides a type helper DeclareMachine to simplify this process:

import { createMachine, type DeclareMachine } from "@machinist/core";

export type User = DeclareMachine<{
  base: {
    name: string;
    age: number;
  };
  discriminant: "status";
  states: {
    active: {
      lock(reason: string): User["locked"];
    };
    locked: {
      lockReason: string;

      unlock(): User["active"];
      ban(): User["banned"];
    };
    banned: {
      bannedAt: Date;
    };
  };
}>;

const userMachine = createMachine<User>({/* ... */});

For every member it will add the properties from base, as well as the discriminant with the provided name (e.g. locked -> status: "locked").
The resulting type is a map that indexes each member by its discriminant (e.g. User["locked"]), while the union itself is indexed under string (e.g. User[string]).
It has the benefits of only having to declare and export a single type, reducing boilerplate, and preventing inconsistencies.

FAQ

- Why are transitions immutable?

Correctly infering the new type of an instance after a transition is easier if it returns a new instance, rather than mutating the original one.
Immutability also makes it easier to historicize and compare previous states (like done in the onTransition callback).

- What's the difference with XState?

The main difference is that XState is event-driven while Machinist is not.
With XState the caller dispatches an event that will be interpreted by the machine, to potentially trigger a transition. If the machine doesn't define a transition for the current state and event, then the event is silently dropped.

On the other hand with Machinist the caller directly invokes the transition like a normal method, and it's up to the same caller to ensure that the machine is in a valid state before doing so.
Thanks to discriminated unions the compiler can automatically narrow the type of the state inside of the condition, and list every valid transitions for the current state. That means no phantom events, the type of the new state is statically known, and the caller is naturally nudged toward also handling the case where the machine is not in the desired state (e.g. not rendering the ban button if the user is already in the banned state).

The other obvious difference is that Machinist is a small library exporting two functions, while XState has a much larger API surface, bundle size, and number of supported features.

Join libs.tech

...and unlock some superpowers

GitHub

We won't share your data with anyone else.