Skip to content

Bringing State Machines in effect

The new @typeonce/effect-machine library is out, and it brings state machines and state charts into effect. Composable and fully type safe, just like effect. Let me give you an introduction.


Software
2 min read
On this page

@typeonce/effect-machine is out πŸš€

It's my attempt to bring state charts and state machines into effect

The API is there, it supports all the main state charts features, and it's already usable πŸ‘Œ

A few notes on why I am doing this, and a quick intro for you πŸ‘‡


The effect model for state charts

If you understand the internals of effect, you realize how convenient it gets to build on top of it πŸ—οΈ

The core is already there. You just need to focus on your module.

I had the same experience contributing to Trie and IndexedDb πŸ€”

I knew Fiber in effect was already the correct primitive to build on top.

The core addition for a state chart module is a nice API for modelling machines, and then wiring fibers for actors composition.

Plus adding all sort of type-safety to the API (just like I did for IndexedDb). All meant to allow agents to just run with it.

So here we go πŸ‘‡

Building state machines with effect

@typeonce/effect-machine is already out, pinned to the latest beta of effect v4:

pnpm add @typeonce/effect-machine

The goal of the project is for it to eventually be part of the native effect package πŸ”œ

A machine starts from states and events. And it's all made from schemas, no need of effect-machine at all:

  • A single Count state that stores a value: number
  • Two events to Increment/Decrement
import { Schema } from "effect"

const State = Schema.TaggedUnion({
  Count: { value: Schema.Number }
})

export const Event = Schema.TaggedUnion({
  Increment: {},
  Decrement: {}
})

A machine is modelled using Machine.defineStates. In this case, we can just pass the single state:

import { Machine } from "@typeonce/effect-machine"
import { Schema } from "effect"

const State = Schema.TaggedUnion({
  Count: { value: Schema.Number }
})

export const Event = Schema.TaggedUnion({
  Increment: {},
  Decrement: {}
})

const States = Machine.defineStates(State.cases)

States will provide all sort of type-safety checks to the machine and states.

We then create the machine with Machine.make:

  • Pass states from the States definition
  • Pass directly the Event schema
  • Define the initial value, schema verified using the from API
const CounterMachine = Machine.make({
  id: "Counter",
  states: States.states,
  events: [Event],
  initial: () => States.initial.Count.from({ value: 0 })
})

Finally, to handle the actual events, chain the handle call:

export const CounterMachine = Machine.make({
  id: "Counter",
  states: States.states,
  events: [Event],
  initial: () => States.initial.Count.from({ value: 0 })
}).handle({
  Count: {
    on: {
      Increment: ({ state, target }) =>
        target.full.Count.from({ value: state.value + 1 }),
      Decrement: ({ state, target }) =>
        target.full.Count.from({ value: state.value - 1 })
    }
  }
})

It reads like this: "From the Count state, when Increment is sent, move to Count with the value + 1". Same for Decrement, with value - 1.

This is all you need for a working state machine:

import { Machine } from "@typeonce/effect-machine"
import { Schema } from "effect"

const State = Schema.TaggedUnion({
  Count: { value: Schema.Number }
})

export const Event = Schema.TaggedUnion({
  Increment: {},
  Decrement: {}
})

const States = Machine.defineStates(State.cases)

export const CounterMachine = Machine.make({
  id: "Counter",
  states: States.states,
  events: [Event],
  initial: () => States.initial.Count.from({ value: 0 })
}).handle({
  Count: {
    on: {
      Increment: ({ state, target }) =>
        target.full.Count.from({ value: state.value + 1 }),
      Decrement: ({ state, target }) =>
        target.full.Count.from({ value: state.value - 1 })
    }
  }
})

Machines composition

The API already supports features from state charts like compound/parallel/history states and more (all type safe).

It also has machines composition, so that you can run any Effect and send events to machine.

First, for events that are triggered internally, the API distinguishes internalEvents from generic events:

const State = Schema.TaggedUnion({
  Count: { value: Schema.Number },
  Saving: { value: Schema.Number }
})

export const Event = Schema.TaggedUnion({
  Increment: {},
  Decrement: {},
  Save: {}
})

const InternalEvent = Schema.TaggedUnion({
  Saved: { value: Schema.Number }
})

export const CounterMachine = Machine.make({
  id: "Counter",
  states: States.states,
  events: [Event],
  internalEvents: [InternalEvent],
  initial: () => States.initial.Count.from({ value: 0 })
})

An invoked machine can be define independently, and it can run any effect:

const SaveCount = (value: number) =>
  Machine.invokeEffect({
    id: "save-count",
    effect: Effect.sleep("500 millis").pipe(
      Effect.as(value)
    ),
    onSuccess: (savedValue) =>
      InternalEvent.cases.Saved.make({ value: savedValue })
  })

This is then composed inside the main machine with invoke:

export const CounterMachine = Machine.make({
  id: "Counter",
  states: States.states,
  events: [Event],
  internalEvents: [InternalEvent],
  initial: () => States.initial.Count.from({ value: 0 })
}).handle({
  Count: {
    on: {
      Increment: ({ state, target }) =>
        target.full.Count.from({ value: state.value + 1 }),

      Decrement: ({ state, target }) =>
        target.full.Count.from({ value: state.value - 1 }),

      Save: ({ state, target }) =>
        target.full.Saving.from({ value: state.value })
    }
  },

  Saving: {
    invoke: ({ state }) => SaveCount(state.value),

    on: {
      Saved: ({ event, target }) =>
        target.full.Count.from({ value: event.value })
    }
  }
})

The types ensure that the Saved event sent from SaveCount is compatible with the internalEvents of the main machine.

Final implementation:

import { Machine } from "@typeonce/effect-machine"
import { Effect, Schema } from "effect"

const State = Schema.TaggedUnion({
  Count: { value: Schema.Number },
  Saving: { value: Schema.Number }
})

export const Event = Schema.TaggedUnion({
  Increment: {},
  Decrement: {},
  Save: {}
})

const InternalEvent = Schema.TaggedUnion({
  Saved: { value: Schema.Number }
})

const States = Machine.defineStates(State.cases)

const SaveCount = (value: number) =>
  Machine.invokeEffect({
    id: "save-count",
    effect: Effect.sleep("500 millis").pipe(
      Effect.as(value)
    ),
    onSuccess: (savedValue) =>
      InternalEvent.cases.Saved.make({ value: savedValue })
  })

export const CounterMachine = Machine.make({
  id: "Counter",
  states: States.states,
  events: [Event],
  internalEvents: [InternalEvent],
  initial: () => States.initial.Count.from({ value: 0 })
}).handle({
  Count: {
    on: {
      Increment: ({ state, target }) =>
        target.full.Count.from({ value: state.value + 1 }),

      Decrement: ({ state, target }) =>
        target.full.Count.from({ value: state.value - 1 }),

      Save: ({ state, target }) =>
        target.full.Saving.from({ value: state.value })
    }
  },

  Saving: {
    invoke: ({ state }) => SaveCount(state.value),

    on: {
      Saved: ({ event, target }) =>
        target.full.Count.from({ value: event.value })
    }
  }
})

The library is "unstable", but just like effect beta, it can already be used quite successfully.

Expect breaking changes, new features, and all sort of API renaming πŸ”œ

See you next πŸ‘‹

Get next Wednesday’s issue

The next focused idea arrives by email. Free, and easy to leave at any time.

Free, no spam, unsubscribe anytime. See the privacy policy.