3 min read

tstate - strongly typed Typescript state machines

If you read this blog or my digital garden you'll know I like working with state machines in the frontend. There's plenty of reasons for this, but the two main ones are that user interaction maps really well to state machines, and so do shared background processes like interactions with APIs or local storage.

Anyway, XState is good for this. However, I don't think it's good enough. Its typegen feels like a dirty fix, it doesn't strongly type its context per state, and its typings are very complicated to use[1].

So I'm creating tstate, a codegenless, very strictly typed, pure Typescript, composable state machine library. It's based on some of my learnings from using XState quite extensively in production applications, and some of my dreams of what I want libraries to be.

How to use tstate

It's early days. Importantly, I'm still working on getting context to work ergonomically. However, as a minimalist state machine library it actually works. Here's some examples pulled from the tests:

const machine = createStateMachine(
  {
    init: createStateDefinition({
      on: {
        foo: {
          target: "bar",
        },
        bar: {
          target: "init",
        },
      },
    }),
    bar: createStateDefinition({
      on: {
        baz: {
          target: "init",
        },
      },
    }),
  },
  "init"
);

The machine variable is an instance of the machine, in its initial init state. It has a send function that accepts valid events for that state (in this case foo and bar):

const res = machine.send("foo");
expect(res.value).toEqual("bar");

You'll see that .send("foo") returns a new machine instance that's in the target state (bar). This, again, is strictly typed:

const res2 = res.send("baz");
expect(res2.value).toEqual("init");

Type architecture

In the tstate finite state machine, typing is enforced from the inside out, starting at the createStateDefinition function. This function defines a single state and its transitions in your state machine. It looks something like this:

function createStateDefinition<const D extends StateDefinition<…>>(definition: D) {
  return definition;
}

The function takes the cont generic parameter of type StateDefinition. This generic should always be inferred by Typescript, and it'll enforce const typing o our output StateDefinition type so that the next function can use that information to enforce its own typings.

That function is createStateMachine. This is where you define a map of all your state names to their respective StateDefinition objects. Looks (a little) like this:

function createStateMachine<const D extends MachineDefinition<…>>(config: D) {
  // State machine logic here
}

Its MachineDefinition type (again const) shares some generics with StateDefinition. It'll only accept StateDefinitions that use the states listed as keys of the MachineDefinition object. This ensures you cannot define a transition into a non-existent state.

While the first function is basically a simple passthrough that ensures ergonomic strict typing, this second function actually creates an state machine with a current state (the initial state) and a .send method to transition to other states. This method only accepts valid events.

Whereto next?

Up next is context. I'm working on adding strong typing of context next to states. This means that when you've asserted that a machine is in a certain state, you'll also know the shape of the context. I think this is a significant improvement over XState's status quo[2]. To do this, I also need to build transition actions to mutate from the current context type into the new type.

After that I have a short list of things I want to work on:

  • "Nested" states: in reality, this should be connector utilities between separately defined machines. I think it'll be much simpler, possibly at the loss of some ergonomics.
  • Utilities for connections with other state machines, like promises, callback functions, and observables.
  • Integrations with React and associated.

If you have opinions about these, feel free to hit me up in Github Discussions


  1. Honestly, I might be wrong on any or all of these... except typegen. And typegen is enough of an issue for me to justify this post. ↩︎

  2. In XState, the type of the context is global for the entire machine. That means that if you're dealing with loading and ready states, you still have to do != null checks or ! overrides wherever you're using the context. ↩︎