@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 π
The API of effect-machine is getting cleaner π§Ή While keeping full type safety, schemas, and all state charts features
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
TrieandIndexedDbπ€
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-machineThe goal of the project is for it to eventually be part of the native
effectpackage π
A machine starts from states and events. And it's all made from schemas, no need of effect-machine at all:
- A single
Countstate that stores avalue: 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
statesfrom theStatesdefinition - Pass directly the
Eventschema - Define the initial value, schema verified using the
fromAPI
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 π
