Skip to content

Setup

machine() builds and runs a machine. setup is how you author the config with full type-checking, via two paths off one entry: setup.infer() (types inferred from the literal) and setup.as<Ctx, Ev>() (you pin the types, names become compile-checked).

import { machine, act } from '@dunky.dev/state-machine'
const toggle = machine({
initial: 'inactive',
context: { count: 0 },
states: {
inactive: {
on: {
flip: { target: 'active', actions: act($ => ({ count: $.context.count + 1 })) },
},
},
active: {
on: { flip: { target: 'inactive' } },
},
},
})
toggle.start()
toggle.send({ type: 'flip' })
toggle.state // 'active'
toggle.context // { count: 1 }

Returns a service, built but not running. Call .start() to boot effects and watchers, .stop() to tear them down. .send() dispatches events; .state and .context are always readable.

Infers State, Context, and Event straight from the literal: no type arguments, no manual annotations. Named guards / actions / effects / delays stay as loose strings.

import { setup } from '@dunky.dev/state-machine'
const config = setup.infer().createMachine({
initial: 'closed',
context: { open: false },
states: {
closed: { on: { toggle: { target: 'open' } } },
open: { on: { toggle: { target: 'closed' } } },
},
})

Everything below was inferred from that object. You wrote no types:

// State inferred as 'closed' | 'open' (from the `states` keys)
// Context inferred as { open: boolean } (from `context`)
// Event inferred as { type: 'toggle' } (from the `on` keys)
const m = machine(config)
m.matches('open') // ✅ autocompletes 'closed' | 'open'
m.matches('half') // ✗ type error: not a known state
m.send({ type: 'tooggle' }) // ✗ type error: not a known event

Inside the config, act callbacks get the same inference: $.context.open is boolean, and the event in a handler is narrowed to the one that triggered it. Nothing here was declared; it all flows from the literal.

Pin Context and Event explicitly, then name a registry of guards / actions / effects / delays. Every reference in the config is compile-checked and autocompleted against the registry keys; a typo is a type error.

import { setup } from '@dunky.dev/state-machine'
type Ctx = { open: boolean; locked: boolean }
type Ev = { type: 'toggle' }
const { createMachine } = setup.as<Ctx, Ev>().config({
guards: {
isUnlocked: ({ context }) => !context.locked,
},
actions: {
logOpen: ({ context }) => console.log('open:', context.open),
},
})
const config = createMachine({
initial: 'closed',
context: { open: false, locked: false },
states: {
closed: {
on: {
toggle: { target: 'open', guard: 'isUnlocked', actions: 'logOpen' },
},
},
open: {
on: { toggle: { target: 'closed' } },
},
},
})

The two paths are identical at runtime — same builder underneath. The difference is entirely type-level, and comes down to one question: do you want guard / action / effect / delay names compile-checked?

  • setup.infer() — lightweight path. Config is a self-contained literal (inline functions, or you’re fine with loose string names). You get State / Context / Event inference for free with zero annotations. Good for small machines and prototyping.
  • setup.as<Ctx, Ev>() — checked path. You maintain a named registry of guards / actions / effects / delays and want every reference in the config verified and autocompleted. The cost is pinning Ctx / Ev yourself and adding the .config() step; the payoff is that a typo or a deleted implementation becomes a compile error instead of a runtime surprise.

Under .infer(), named references stay loose strings (typed as string, not a union of your registry keys). Every line below compiles clean and only misbehaves at runtime — a silent no-op:

// setup.infer() — none of these are type errors:
actions: 'logOpn' // typo → compiles, silently does nothing
guard: 'isUnlockd' // typo → compiles
effects: 'trak' // typo → compiles
after: {
opnDelay: {
}
} // typo → compiles

The same misspellings under setup.as<Ctx, Ev>().config(...) are compile errors, because .config() narrows the name unions to the registry’s actual keys. You also lose autocomplete for those names on the .infer() path — the editor has no union to suggest from.