@bemedev/app
A TypeScript library for building finite state machines with a fully type-safe, declarative API. It models states, transitions, context, asynchronous operations, and reactive streams through a unified actors model.
The core idea: write machines as pure data, wire implementations separately, generate types automatically.
Philosophy
Expand
The machine defines what can happen. The interpreter makes it happen.
A machine is purely declarative. Its configuration is plain data: state names, transitions, guard names, action names. It never calls external code, never imports side-effects. You can serialise, clone, inspect, and test it in complete isolation.
Machine config provideOptions / addOptions
───────────── ────────────────────────────
states: { actions: {
idle: { fetchUser: assign(...),
on: { canFetch: ({ context }) => ...,
FETCH: { }
guards: 'canFetch',
target: 'loading',
actions: 'fetchUser',
}
}
}
}
The interpreter receives the machine and brings it to life at runtime. It holds context, processes events, schedules timers, and subscribes to actors.
Names, not references
Every action, guard, delay, and actor is referred to by a string name in the config. This is intentional:
- The machine config is serialisable (JSON-friendly)
- Implementations are swappable without touching the config
- Tests can verify the config shape independently of side-effects
- The CLI code generator can statically extract all names and produce accurate TypeScript types
Actors — two kinds of external work
| Actor type | Shape | Direction | Control |
|---|---|---|---|
emitters |
() => Pausable<T> |
Source → Machine (read-only) | None |
children |
() => Interpreter<...> |
Bidirectional | sendTo API |
An emitter is a pausable stream the machine listens to. It never receives events from the machine. A child is a nested interpreter the parent can talk to.
Installation
npm install @bemedev/app
# or
pnpm add @bemedev/app
Requirements: Node.js ≥ 24 · TypeScript ≥ 6.0
Quick Start
import { createMachine, interpret, typings } from '@bemedev/app';
// 1. Declare the machine (pure data)
const machine = createMachine(
{
initial: 'idle',
states: {
idle: { on: { START: '/running' } },
running: { on: { STOP: '/idle' } },
},
},
typings({ eventsMap: { START: 'primitive', STOP: 'primitive' } }),
);
// 2. Create and start an interpreter
const service = interpret(machine);
service.start();
// 3. Send events and read state
console.log(service.value); // 'idle'
service.send('START');
console.log(service.value); // 'running'
service.send('STOP');
console.log(service.value); // 'idle'
// 4. Dispose cleanly
await service[Symbol.asyncDispose]();
Table of Contents
- Philosophy
- Installation
- Quick Start
- 1. Machine Configuration
- 2. Typings System
- 3. Interpreter
- 4. State Subscriptions
- 5. Actions
- 6. Guards
- 7. Transitions: on, after, always
- 8. Activities
- 9. Actors: Emitters
- 10. Actors: Children
- 11. Tags
- 12. Registry & Code Generation
- 13. Legacy Options (
_legacy) - 14. Internal Utilities
- 15. API Reference
- Changelog
- Contributing
- License
- Author
1. Machine Configuration
Expand
createMachine(config, types?) is the entry point. The first argument is
the machine config; the second is optional type information.
import { createMachine, typings } from '@bemedev/app';
const machine = createMachine(
{
// The state the machine starts in
initial: 'idle',
// Root-level actors (emitters/children) shared across all states
actors: {
/* optional */
},
// All states
states: {
idle: {
// Entry/exit side-effects (by name)
entry: 'onEnterIdle',
exit: 'onExitIdle',
// Event-driven transitions
on: { FETCH: '/loading' },
// Recurring timed action while this state is active
activities: { POLL: 'refreshToken' },
},
loading: {
// Automatic transition after a delay
after: { TIMEOUT: '/error' },
// Unconditional transitions evaluated on entry
always: [{ guards: 'isDone', target: '/success' }, '/error'],
on: {
SUCCESS: { actions: 'storeData', target: '/success' },
ERROR: '/error',
},
},
success: {},
error: {},
},
},
typings({
context: {
data: typings.array('string'),
error: typings.maybe('string'),
},
eventsMap: {
FETCH: 'primitive',
SUCCESS: { data: typings.array('string') },
ERROR: { message: 'string' },
},
}),
);
createConfig(config)
Share a typed config object without creating a full machine:
import { createConfig } from '@bemedev/app';
export const myConfig = createConfig({
initial: 'idle',
states: { idle: {}, done: {} },
});
Synchronous vs Asynchronous Machines
By default, machines are asynchronous (all transitions and actions are
wrapped in promises to naturally support async operations). If you require
strict synchronous execution (e.g., for performance or integration with
synchronous UI frameworks), you can opt into a synchronous machine via the
sync typings configuration:
const syncMachine = createMachine(
{
initial: 'idle',
states: { idle: {} },
},
// Setting sync to 'sync' forces synchronous execution
{ sync: 'sync' }),
);
Synchronous machines throw a type error if you attempt to use async
configurations. The interpret function automatically detects and runs
synchronous machines without creating promises.
Strict Configuration Type Checking (NoExtraKeysConfig)
To ensure your machine configs are free of spelling errors and invalid
configuration keys, @bemedev/app enforces compile-time strictness. If you
add any unrecognized options at the root or within state configuration
blocks, TypeScript will raise an error.
For example, typing init instead of initial will produce a compilation
error:
const machine = createMachine({
init: 'idle', // ❌ Type error: Object literal may only specify known properties...
states: {
idle: {},
},
});
This ensures that state hierarchies, actions, and guards are always defined using correct keywords.
2. Typings System
Expand
TypeScript cannot always infer complex generic types from nested object
literals alone. @bemedev/app relies on @bemedev/typings to let you
describe TypeScript types as plain schema values that the machine can read
at compile and build time.
Schema Builders
Type schemas are built using type from @bemedev/typings, along with
structural helpers (e.g. from @bemedev/typings/helpers or dynamically
within the type callback):
import { type } from '@bemedev/typings';
import {
array,
partial,
optional,
record,
union,
litterals,
} from '@bemedev/typings/helpers';
// Define schemas
const user = type(({ partial }) =>
partial({
name: 'string',
age: 'number',
}),
);
Supported Schemas
- Primitives:
'string','number','boolean','undefined','never'. - Objects:
partial({ ... })for optional fields,any({ ... })for strict objects. - Collections:
array(...)for arrays,record(...)for maps,tuple(...)for tuples. - Unions & Literals:
union(...)andlitterals(...).
Full Machine Typings Example
To attach types, supply them as the third argument to createMachine:
import { createMachine } from '@bemedev/app';
import { type } from '@bemedev/typings';
const machine = createMachine(
'auth-machine',
{
initial: 'idle',
states: {
idle: { on: { FETCH: '/loading' } },
loading: {},
},
},
{
// Public context (exposed via service.context)
context: type(({ partial, array }) =>
partial({
items: array('string'),
error: optional('string'),
count: 'number',
}),
),
// Private context (not exposed to subscribers)
pContext: type(({ partial }) =>
partial({
token: 'string',
}),
),
// All events the machine can receive
eventsMap: type(({ partial, array }) => ({
FETCH: 'never',
SUCCESS: { data: array('string') },
ERROR: { message: 'string' },
})),
// Force synchronous execution if true
sync: true,
},
);
3. Interpreter
Expand
The interpreter is the runtime engine. It receives a configured machine, holds state and context, processes events, schedules timers, and manages actor subscriptions.
import { interpret } from '@bemedev/app';
const service = interpret(machine, {
// Initial public context (must satisfy the declared type)
context: { items: [], error: undefined, count: 0 },
// Initial private context (optional)
pContext: { token: undefined },
// 'strict' (default) — throws on unknown events/options
// 'normal' — silently ignores unknown events
mode: 'strict',
// Use exact interval timing (default: true)
exact: true,
});
// Must call start() before sending events
service.start();
// Send events — string shorthand or full event object
service.send('FETCH');
service.send({ type: 'SUCCESS', payload: { data: ['a', 'b'] } });
// Read state
service.value; // current state value
service.context; // current public context
service.status; // 'idle' | 'working' | 'stopped'
service.state; // full snapshot
service.config; // current state node config
service.tags; // active tags for the current state
service.mode; // 'strict' | 'normal'
// Pause / resume all timers and activities
service.pause();
service.resume();
// Stop the service (async — waits for all promises)
await service[Symbol.asyncDispose]();
// or synchronously:
service.dispose();
Adding options at runtime
addOptions mutates the service (useful for dynamic wiring):
service.addOptions(({ assign }) => ({
actions: {
storeData: assign('context.items', ({ event }) => event.payload.data),
},
}));
provideOptions returns a new service (immutable):
const enrichedService = service.provideOptions(({ voidAction }) => ({
actions: {
log: voidAction(() => console.log('state changed')),
},
}));
4. State Subscriptions
Expand
Subscribe to all state changes
The subscriber receives (previousState, currentState) on every
transition:
const sub = service.subscribe((prev, curr) => {
console.log(`${prev.value} → ${curr.value}`);
console.log('New context:', curr.context);
});
// Later:
sub.unsubscribe();
Subscribe to specific events
React only to named events with an object subscriber:
const sub = service.subscribe({
SUCCESS: ({ payload }) => {
console.log('Got data:', payload.data);
},
ERROR: ({ payload }) => {
console.error('Failed:', payload.message);
},
// Catch-all for any other event
else: () => console.log('Other transition'),
});
sub.close();
5. Actions
Expand
Actions are side-effects that run during transitions. They are always
declared by name in the config and implemented in provideOptions /
addOptions. The library provides a set of action helpers that cover
the most common patterns.
All helpers are injected as parameters of the provideOptions callback:
machine.provideOptions(({
assign, voidAction, batch, filter, erase,
resend, forceSend, isValue, isNotValue,
pauseActivity, resumeActivity, stopActivity,
sendTo,
}) => ({
actions: { ... },
guards: { ... },
delays: { ... },
actors: { ... },
}));
5.1 assign
Updates context values using decomposed dot-notation paths.
actions: {
// Set a leaf value
increment: assign(
'context.count',
({ context }) => context.count + 1,
),
// Replace the entire context
reset: assign('context', () => ({
items: [],
error: undefined,
count: 0,
})),
// Scoped to an actor event — runs only when that actor emits
storeData: assign('context.items', {
'dataStream::next': ({ payload }) => [payload],
'dataStream::error': () => [],
}),
}
The path follows @bemedev/decompose conventions: 'context',
'context.field', 'context.nested.deep'.
You can also perform multi-variable assignments by passing an array of keys:
// Assign multiple variables from an array-returning function
updateCoordinates: assign(['context.x', 'context.y'], () => [100, 200]);
5.2 voidAction
Side-effect only — returns nothing, never modifies context.
actions: {
logTransition: voidAction(({ event }) => {
console.log('Event received:', event.type);
}),
// Scoped to an actor event
handleStreamError: voidAction({
'dataStream::error': ({ payload }) => {
Sentry.captureException(payload);
},
}),
}
5.3 batch
Groups multiple actions into a single named action. Useful when a transition needs to perform several operations atomically.
actions: {
clearForm: batch(
erase('context.name'),
erase('context.email'),
erase('context.age'),
),
// Compose existing actions via _legacy
doubleIncrement: batch(
_legacy.actions.increment!,
_legacy.actions.increment!,
),
}
5.4 filter & erase
filter — removes elements from arrays, object arrays, or records
stored in context:
actions: {
// Keep only even numbers
keepEvens: filter('context.numbers', (n: number) => n % 2 === 0),
// Keep active users
keepActive: filter('context.users', ({ active }) => active === true),
// Keep high-scoring entries in a record
keepTopScores: filter('context.scores', score => score >= 90),
}
erase — sets a context property to undefined:
actions: {
clearError: erase('context.error'),
clearToken: erase('context.pContext.token'),
clearAll: batch(
erase('context.items'),
erase('context.error'),
),
}
5.5 resend & forceSend
Re-dispatch events from within an action.
resend(event)— only dispatches if the machine is not in a blocked state (e.g.'stopped').forceSend(event)— always dispatches, regardless of machine state.
actions: {
retryFetch: resend('FETCH'),
alwaysIncrement: forceSend('INCREMENT'),
}
5.6 Async actions & AsyncOptions
All action helpers (assign, voidAction, sendTo) accept async
functions. The interpreter's action pipeline is fully async and awaits each
step sequentially.
You can configure execution behavior by passing an options object as the last argument:
catch: A curried function(err) => Actionthat handles rejections inline. If omitted, errors flow to the interpreter's internal_addErrorchannel.then: An optional action to execute sequentially after successful resolution.max: An optional timeout in milliseconds. If execution exceeds this, it is aborted.
actions: {
// Async assign with inline catch and then chaining
loadUser: assign(
'context.user',
async ({ event }) => {
const res = await fetch(`/api/users/${event.payload.id}`);
return res.json();
},
{
// Handle rejection by assigning the error message to context
catch: (err) => assign('context.error', () => err.message),
// Chain another action after successful user loading
then: voidAction(({ context }) => {
console.log(`User ${context.user.name} loaded successfully!`);
}),
// Enforce a 5-second timeout limit
max: 5000,
},
),
// Async void — no options → errors propagate to the interpreter
trackAnalytics: voidAction(
async ({ context }) => {
await analytics.track('state_change', { userId: context.userId });
},
),
}
6. Guards
Expand
Guards are predicates that gate transitions. They receive the current
state snapshot and return a boolean (or Promise<boolean> in async
machines).
machine.provideOptions(({ isValue, isNotValue }) => ({
guards: {
// Built-in helpers — compare a context path to a value
isEmpty: isValue('context.items', []),
hasToken: isNotValue('context.pContext.token', undefined),
// Synchronous custom predicate
isAuthenticated: ({ context }) =>
context.token !== undefined && !isExpired(context.token),
// Predicate with event payload
isValidInput: ({ event }) =>
event.type === 'SUBMIT' && event.payload.value.length > 0,
// Async predicate — only available in AsyncInterpreter
// A rejected promise resolves to false (error-safe)
hasPermission: async ({ context }) => {
const res = await fetch(`/api/permissions/${context.userId}`);
return res.ok;
},
},
}));
Async guards are fully supported in
AsyncInterpreter. Predicates are evaluated sequentially and short-circuit on the firstfalse(fail-fast). A predicate that throws or rejects is treated asfalse. Sync machines do not support async guards.
Using guards in config
Guards are referenced by name in on, after, and always:
states: {
idle: {
on: {
// Single guard
FETCH: { guards: 'isAuthenticated', target: '/loading' },
// Multiple candidates — first match wins (OR semantics)
SUBMIT: [
{ guards: 'isValid', target: '/success' },
{ guards: 'hasErrors', target: '/error' },
'/fallback', // no guard → always matches (catch-all)
],
},
always: [
{ guards: 'isEmpty', target: '/empty' },
],
},
}
AND/OR guard composition
Guards can be composed with and / or objects directly in the config:
on: {
SUBMIT: {
guards: { and: ['isAuthenticated', 'isValid'] },
target: '/success',
},
RECOVER: {
guards: { or: ['isAdmin', 'hasRetries'] },
target: '/retry',
},
}
7. Transitions: on, after, always
Expand
on — event-driven
on: {
// Simple target
CANCEL: '/idle',
// With guard + actions
SUBMIT: {
guards: 'canSubmit',
actions: 'validateForm',
target: '/loading',
},
// Multiple candidates — evaluated top-to-bottom, first match wins
RESPOND: [
{ guards: 'isOk', target: '/success' },
{ guards: 'isRetry', target: '/loading' },
'/error', // fallback — no guard
],
}
after — timed / delayed
The transition fires automatically after the named delay elapses. If multiple delays are defined, the shortest one whose guard passes wins.
// Simple: fixed number (ms)
const machine = createMachine(
{
initial: 'idle',
states: {
idle: { after: { POLL: '/refreshing' } },
refreshing: { on: { DONE: '/idle' } },
},
},
defaultT,
).provideOptions(() => ({
delays: { POLL: 5000 },
}));
// → transitions to 'refreshing' after 5 s
// Dynamic: function of current state
delays: {
RETRY_DELAY: ({ context }) => context.retryCount * 1000,
}
// Multiple delays — shortest passing guard wins
states: {
waiting: {
after: {
FAST: { guards: 'networkAvailable', target: '/online' },
SLOW: '/offline',
},
},
}
delays: { FAST: 2000, SLOW: 10000 }
// If 'networkAvailable' is true → FAST fires at 2 s
// Otherwise → SLOW fires at 10 s
always — immediate / eventless
Evaluated every time the state is entered, before any event is processed. First matching guard wins. No match → nothing happens.
states: {
gate: {
always: [
{ guards: 'isAdmin', target: '/adminDashboard' },
{ guards: 'isLoggedIn', target: '/dashboard' },
'/login', // catch-all fallback
],
},
}
Order matters.
alwaysis evaluated synchronously on entry. Circular chains (A always → B always → A) are treated as no-ops.
8. Activities
Expand
An activity is an action that fires repeatedly on a named interval while the state is active. It supports pause, resume, and stop controls.
const machine = createMachine(
{
initial: 'polling',
states: {
polling: {
// 'refresh' action fires every POLL ms
activities: { POLL: 'refresh' },
on: {
PAUSE: { actions: 'pausePoll' },
RESUME: { actions: 'resumePoll' },
STOP: { actions: 'stopPoll' },
},
},
},
},
typings({
context: { data: typings.maybe('string') },
eventsMap: {
PAUSE: 'primitive',
RESUME: 'primitive',
STOP: 'primitive',
},
}),
).provideOptions(
({ assign, pauseActivity, resumeActivity, stopActivity }) => ({
actions: {
refresh: assign(
'context.data',
async () => (await fetchData()).value,
),
// The path '/polling::POLL' identifies the activity (state::delay)
pausePoll: pauseActivity('/polling::POLL'),
resumePoll: resumeActivity('/polling::POLL'),
stopPoll: stopActivity('/polling::POLL'),
},
delays: { POLL: 3000 },
}),
);
The activity refresh runs every 3 000 ms. PAUSE freezes it (timer
stopped, context preserved), RESUME restarts the timer, STOP terminates
it permanently for the current state visit.
9. Actors: Emitters
Expand
Core principle — emitters are NEVER touched during the flow.
An emitter is a pausable stream source that the machine subscribes to on state entry and unsubscribes from on state exit. The machine only reacts to its emissions. It never sends events to the emitter.
9.1 Lifecycle
┌─────────────────────┐ next/error/complete ┌───────────────┐
│ Pausable<T> │ ───────────────────────► │ Machine │
│ (your RxJS obs, │ │ handlers │
│ websocket, etc.) │ ◄── nothing │ │
└─────────────────────┘ └───────────────┘
▲ │
│ subscribe + start on state entry │ stop on exit / re-entry
└────────────────────────────────────────────────┘
Config — declare the emitter name and its handlers:
actors: { dataStream: { next: { actions: ['appendData'] }, error: { actions: ['handleStreamError'] }, complete: { actions: ['onStreamDone'] }, }, }Implementation — provide a factory
() => Pausable<T>:.provideOptions(() => ({ actors: { emitters: { dataStream: () => createPausable(myObservable$), }, }, }))Pausable<T>is a framework-agnostic interface exported by this library. Any object satisfying{ subscribe, start, stop, pause, resume }qualifies. UsecreatePausablefrom@bemedev/rx-pausableto wrap RxJS observables — it is not a required dependency.Runtime — the interpreter manages everything automatically:
- State entry → factory called →
subscribe()+start() - Each emission → routed to the matching handler
- State exit or service stop →
stop() - Re-entering the state → new
Pausablefrom scratch
- State entry → factory called →
9.2 Simple emitter — accumulating values
import { createMachine, typings, interpret } from '@bemedev/app';
import { createPausable } from '@bemedev/rx-pausable';
import { interval, map, take } from 'rxjs';
const machine = createMachine(
{
initial: 'active',
actors: {
ticker: {
next: { actions: ['accumulate'] },
complete: { actions: ['onDone'] },
},
},
states: { active: {} },
},
typings({
context: 'number',
actorsMap: {
emitters: { ticker: { next: 'number', error: 'never' } },
},
}),
).provideOptions(({ assign, voidAction }) => ({
actions: {
accumulate: assign('context', {
'ticker::next': ({ payload, context }) => context + payload,
}),
onDone: voidAction(() => console.log('Stream complete')),
},
actors: {
emitters: {
ticker: () =>
createPausable(
interval(200).pipe(
take(5),
map(i => (i + 1) * 5),
),
),
},
},
}));
const service = interpret(machine, { context: 0 });
service.start();
// Emissions: 5, 10, 15, 20, 25
// context after all 5 emissions: 75
9.3 Error handling
When the source emits an error, the error handler fires. The machine
remains healthy — it simply routes the error value to the declared actions.
actors: {
live: {
next: { actions: ['store'] },
error: { actions: ['logError'] },
},
}
// ...
actions: {
logError: voidAction({
'live::error': ({ payload }) => console.error('Stream error:', payload),
}),
}
9.4 State-scoped emitters
Emitters declared inside a specific state only run while that state is active. Exiting unsubscribes; re-entering creates a fresh subscription.
states: {
idle: { on: { START: '/streaming' } },
streaming: {
actors: {
feed: { next: { actions: ['buffer'] } },
},
on: { STOP: '/idle' },
},
}
// feed is only active while in 'streaming'.
// Entering 'idle' → unsubscribed.
// Re-entering 'streaming' → new Pausable created from scratch.
9.5 Emitters vs Children
| Aspect | Emitters | Children |
|---|---|---|
| Direction | Source → Machine only | Bidirectional (parent child) |
| Machine control | None — strictly passive | sendTo sends events to the child |
| Lifecycle | subscribe / stop |
interpret(...) / interpreter stop |
| Pause / Resume | Via Pausable protocol |
Via child interpreter |
| Re-entry | New Pausable from scratch |
Existing or new interpreter |
10. Actors: Children
Expand
A child actor is a nested interpreter. The parent can send events to it
via sendTo, and the child's events bubble up via declared on handlers.
Context can be mapped from child to parent (pContext).
10.1 Sending events to a child
const child = createMachine(
{
initial: 'idle',
states: {
idle: { on: { PING: '/pong' } },
pong: {},
},
},
typings({ eventsMap: { PING: 'primitive' } }),
);
const parent = createMachine(
{
actors: {
worker: {
// When the child emits PONG, run 'notify' in the parent
on: { PONG: { actions: ['notify'] } },
},
},
initial: 'idle',
states: {
idle: {
on: {
// Forward PING to the child when the parent receives it
PING: { actions: ['forwardPing'] },
},
},
},
},
typings({
eventsMap: { PING: 'primitive' },
actorsMap: { children: { worker: { PING: 'primitive' } } },
}),
).provideOptions(({ sendTo, voidAction }) => ({
actions: {
notify: voidAction(() => console.log('child reached pong')),
forwardPing: sendTo(child)(() => ({ to: 'worker', event: 'PING' })),
},
actors: {
children: { worker: () => interpret(child) },
},
}));
10.2 Context mapping (child → parent pContext)
actors: {
authService: {
// Map child's entire context ('.') to parent.pContext.auth
contexts: { '.': 'auth' },
},
}
// Whenever authService.context changes, parent.pContext.auth is updated.
// Mapping is one-way — parent reads, never writes.
11. Tags
Expand
Tags are metadata labels on states. They let consumers ask "what category is the machine in?" without hard-coding state names. A state can carry multiple tags.
const machine = createMachine(
{
initial: 'idle',
states: {
idle: { tags: ['idle', 'ready'] },
loading: { tags: ['busy'] },
error: { tags: ['failed'] },
success: { tags: ['done'] },
},
},
typings({ eventsMap: {} }),
);
const service = interpret(machine);
service.start();
service.tags; // ['idle', 'ready']
service.send('FETCH');
service.tags; // ['busy']
Tags in action callbacks
Tag literals are propagated into provideOptions callbacks as a typed
union — enabling narrowing inside actions:
.provideOptions(({ voidAction }) => ({
actions: {
renderUI: voidAction(({ tags }) => {
// tags: 'idle' | 'ready' | 'busy' | 'failed' | 'done' | undefined
if (tags === 'busy') showSpinner();
if (tags === 'failed') showErrorBanner();
}),
},
}))
12. Registry & Code Generation
Expand
@bemedev/app ships a CLI and a type-level registry that together give
you full compile-time types for every machine in your project — paths,
events, options, context — with zero manual annotation.
The CLI helpers are provided by the companion package @bemedev/app-cli, a
complementary library for better typing and CLI-driven code generation,
similar in purpose to TanStack Start.
The pattern is inspired by TanStack Router's declare module augmentation.
CLI Binary Entry Point
The package includes a built-in CLI executable. After installation, invoke
commands directly via npx:
npx @bemedev/app generate
npx @bemedev/app watch
Or install globally to use the CLI without npx:
npm install -g @bemedev/app
app generate
app watch
12.1 Machine file convention
Name your machine files with the .machine.ts (or .fsm.ts) suffix:
src/
auth/
auth.machine.ts ← discovered by the CLI
checkout/
checkout.machine.ts ← discovered by the CLI
app.gen.ts ← generated — do not edit manually
Inside a machine file, call registerMachine to enroll the machine in the
global registry:
// src/auth/auth.machine.ts
import { createMachine, registerMachine, typings } from '@bemedev/app';
const machine = createMachine(
{
/* config */
},
typings({
/* types */
}),
);
registerMachine('./src/auth/auth.machine.ts', machine);
export { machine };
12.2 CLI: generate
Run once to scan all machine files and produce app.gen.ts:
# Via npx (after npm install @bemedev/app)
npx @bemedev/app generate
# Custom output path
npx @bemedev/app generate --output src/app.gen.ts
# Exclude additional directories
npx @bemedev/app generate --excludes temp build coverage
# Preview output without writing to disk
npx @bemedev/app generate --dry-run | less
Or use the npm script defined in package.json:
pnpm run generate
12.3 CLI: watch / dev
Long-running watcher that regenerates app.gen.ts automatically on every
machine file change. Ideal during development:
npx @bemedev/app watch
# or the alias:
npx @bemedev/app dev
# Alongside a dev server
pnpm run dev &
pnpm run generate:watch
Behaviour:
- Performs a full initial generation on startup.
- Watches all
*.machine.tsand*.fsm.tsfiles (excludesnode_modules,lib,distby default). - Debounces 300 ms of filesystem stability before regenerating (handles editor auto-saves and git checkouts gracefully).
- Logs every detected change to stderr.
- Responds to Ctrl+C with a clean watcher shutdown.
12.4 app.gen.ts and the Register interface
app.gen.ts is a TypeScript module augmentation file. It declares:
declare module '@bemedev/app' {
interface Register {
'./src/auth/auth.machine.ts': {
paths: { map: { '/idle': ..., '/loading': ... }; all: string };
events: 'LOGIN' | 'LOGOUT' | 'REFRESH';
options: {
actions: 'setUser' | 'clearUser';
guards: 'isLoggedIn';
delays: never;
// ...
};
pContext?: { token: string | undefined };
tags?: 'authenticated' | 'guest';
};
// ... one entry per discovered machine
}
}
Import app.gen.ts once at your app entry point and every
getMachine, registerMachine, and state path will be fully typed
throughout the codebase:
// main.ts
import './app.gen';
12.5 registerMachine & getMachine
import { registerMachine, getMachine, MACHINES } from '@bemedev/app';
// Register a machine under a path key
registerMachine('./src/auth/auth.machine.ts', machine);
// Retrieve a machine by path key (fully typed via Register)
const authMachine = getMachine('./src/auth/auth.machine.ts');
// authMachine is typed to the exact shape declared in Register
// Access all registered machines (Record<string, AnyMachine>)
console.log(Object.keys(MACHINES));
13. Legacy Options (_legacy)
Expand
Both provideOptions and addOptions receive a second parameter
{ _legacy } containing all options defined in previous calls. This
enables safe composition without manual cross-referencing.
Composing on a Machine
const machine = createMachine(config, types)
.provideOptions(({ assign }) => ({
actions: {
increment: assign(
'context.count',
({ context }) => context.count + 1,
),
},
}))
.provideOptions(({ batch }, { _legacy }) => ({
actions: {
// Reuse 'increment' defined above — no import, no circular reference
doubleIncrement: batch(
_legacy.actions.increment!,
_legacy.actions.increment!,
),
},
}));
Composing on an Interpreter
const service = interpret(machine, { context: 0 });
service.addOptions(({ assign }) => ({
actions: { add5: assign('context', ({ context }) => context + 5) },
}));
service.addOptions(({ batch }, { _legacy }) => ({
actions: {
add10: batch(_legacy.actions.add5!, _legacy.actions.add5!),
},
}));
_legacy properties
| Property | Content |
|---|---|
_legacy.actions |
All previously defined actions |
_legacy.guards |
All previously defined guards |
_legacy.delays |
All previously defined delays |
_legacy.machines |
All previously defined child actors |
_legacy.emitters |
All previously defined emitters |
Key guarantees:
- Frozen —
_legacyis immutable; mutations throw at runtime. - Cumulative — each call sees options from all previous calls, not just the immediately preceding one.
- Type-safe — fully typed; IntelliSense completes option names.
14. Internal Utilities
Expand
These lower-level building blocks are used by the library internally. They are exported for advanced use cases such as building tooling, custom generators, or extending the framework.
14.1 BetterSet<T>
An enhanced Set with optional custom equality, extra collection
operations (union, intersection, difference, symmetricDifference),
and a richer API:
import { createBetterSet } from '@bemedev/app';
const s = createBetterSet<string>();
s.add('a', 'b', 'c');
s.has('b'); // true
s.isEmpty; // false
s.toArray; // ['a', 'b', 'c']
s.size; // 3
// Set algebra
const other = createBetterSet<string>();
other.add('b', 'd');
const common = (a: string, b: string) => a === b;
s.intersection(other, common).toArray; // ['b']
s.difference(other, common).toArray; // ['a', 'c']
// Custom equality (deduplicate by id)
const byId = createBetterSet<{ id: number }>((a, b) => a.id === b.id);
byId.add({ id: 1 }, { id: 1 }); // deduplicated — size = 1
14.2 parseTree
Traverses a machine's NodeConfig and returns a complete structural
analysis in a single pass:
import { parseTree } from '@bemedev/app';
const result = parseTree(machine.config);
result.paths.all; // string[] — all state paths ('/idle', '/loading', ...)
result.paths.map; // typed map: path → NodeConfig
result.actions; // BetterSet<string> — all action names
result.guards; // BetterSet<string> — all guard names
result.delays; // BetterSet<string> — all delay names
result.emitters; // BetterSet<string> — all emitter names
result.children; // BetterSet<string> — all child actor names
result.events; // BetterSet<string> — all event names
result.tags; // BetterSet<string> — all tag values
result.pContextKeys; // BetterSet<string> — pContext mapping keys
result.flat; // flat record: path → NodeConfig
parseTree powers the CLI generator — it extracts everything the type
generator needs from the machine config without executing any runtime code.
14.3 reduceGuards
Flattens nested AND/OR guard unions into a deduplicated flat array:
import { reduceGuards } from '@bemedev/app';
reduceGuards(
{ and: ['isLoggedIn', 'isAdmin'] },
{ or: ['hasRole', 'isOwner'] },
'isActive',
);
// → ['isLoggedIn', 'isAdmin', 'hasRole', 'isOwner', 'isActive']
14.4 betterTimeout
A callback-based timeout helper designed to prevent long-running or hung
async operations. It executes a callback after a specified delay, but
throws a MAX_EXCEEDED error to the onError handler if the delay exceeds
a specified maxTime threshold limit.
import { betterTimeout } from '@bemedev/app/utils';
betterTimeout({
callback: () => {
console.log('Task finished successfully');
},
onError: err => {
console.error('Task timed out:', err.message); // "MAX_EXCEEDED"
},
ms: 3000,
maxTime: 2000, // exceeds maxTime -> triggers onError with MAX_EXCEEDED error
});
15. API Reference
Expand
Machine creation
createMachine(config, types?)
| Parameter | Description |
|---|---|
config |
Machine configuration — initial, states, actors? |
types |
Type definitions via typings(...) — context, eventsMap, actorsMap?, pContext? |
Returns a Machine instance. Chainable via .provideOptions(...).
createConfig(config)
Returns a typed config object (no Machine instance created).
Machine methods
| Method | Mutates | Returns | Description |
|---|---|---|---|
.provideOptions(cb) |
No | New Machine |
Wire implementations (immutable) |
.addOptions(cb) |
Yes | Options object | Add / overwrite options at runtime |
.clone() |
No | New Machine |
Deep clone the machine |
Utility helper exports
The root package now re-exports a small set of generic utility helpers from
./bemedev:
| Helper | Purpose |
|---|---|
_any |
Predicate helper that always returns true |
_unknown |
Predicate helper for unknown checks |
expandFn |
Expands value/function inputs into executable forms |
identify |
Identity helper for strong type inference |
partialCall |
Partial application helper |
partialCallO |
Object-oriented variant of partial application |
switchV |
Value-based switching helper |
switchValue |
Typed switch helper for branching by value |
toArray |
Normalizes value(s) into array form |
trueO |
Object predicate helper returning true |
tupleOf |
Tuple constructor helper preserving literal typings |
interpret(machine, options?)
| Option | Default | Description |
|---|---|---|
context |
— | Initial public context |
pContext |
undefined |
Initial private context |
mode |
'strict' |
'strict' | 'normal' |
exact |
true |
Use exact interval timing |
Interpreter properties
| Property | Type | Description |
|---|---|---|
value |
StateValue |
Current state (string or nested) |
context |
Tc |
Current public context |
status |
'idle' | 'working' | 'stopped' |
Lifecycle status |
state |
StateExtended |
Full state snapshot |
config |
NodeConfig |
Current state node configuration |
mode |
'strict' | 'normal' |
Current execution mode |
tags |
string[] |
Active tags for the current state |
Interpreter methods
| Method | Description |
|---|---|
start() |
Start the service and process entry actions |
send(event) |
Send an event (string or { type, payload }) |
subscribe(subscriber) |
Subscribe to state changes |
pause() |
Pause activities and timers |
resume() |
Resume after pause |
stop() |
Stop the service |
addOptions(cb) |
Mutate the service with new options |
provideOptions(cb) |
Return a new service with additional options |
dispose() |
Synchronous disposal |
await service[Symbol.asyncDispose]() |
Async disposal (waits for in-flight promises) |
State configuration shape
{
type?: 'atomic' | 'compound' | 'parallel' | 'final';
initial?: string;
tags?: string[];
entry?: ActionConfig;
exit?: ActionConfig;
on?: Record<string, TransitionConfig>;
after?: Record<string, TransitionConfig>;
always?: TransitionConfig | TransitionConfig[];
activities?: Record<string, ActionConfig>;
actors?: Record<string, ActorConfig>;
states?: Record<string, StateDefinition>;
}
Transition configuration
type TransitionConfig =
| string // Target path
| {
target?: string;
guards?: GuardConfig;
actions?: ActionConfig;
}
| TransitionConfig[]; // Array — first match wins
Pausable<T> interface
type Pausable<T> = {
subscribe: (observer: EmitterObserver<T>) => void;
start: () => void; // begin consuming source
stop: () => void; // stop and clean up
pause: () => void; // buffer incoming values
resume: () => void; // replay buffer then resume
};
type EmitterObserver<T> = {
next: (value: T) => void;
error: (err: any) => void;
complete: () => void;
};
Typings utilities
@bemedev/app re-exports key type utilities from @bemedev/typings:
| Utility | Source | Purpose |
|---|---|---|
inferT<Schema> |
@bemedev/typings |
Extracts TypeScript type from a schema definition |
inferO<Schema> |
@bemedev/typings |
Extracts TypeScript type from object schema definitions |
inferSh<Schema> |
@bemedev/typings |
Extracts schema shape |
Fn<Args, Return> |
@bemedev/typings |
Helper for strongly-typed functions |
For schema building, import builders directly from
@bemedev/typings/helpers (e.g. array, record, partial, litterals,
union, optional).
Advanced Exported Types
These advanced helper and registry types are exported from @bemedev/app
for strict compile-time checks, tooling integration, or typing extension
points:
| Type | Purpose |
|---|---|
Action2<E, Pc, Tc, T> |
Standard function signature for an action callback (takes StateExtended and returns a sync or async ActionResult). |
ActorsConfigMap |
Core type representing the entire actors registry map (consisting of children, emitters, and promisees definitions). |
ToEventObject<T> |
Utility type that converts event configurations into a unified EventObject interface. |
ToEvents<E, A> |
Combines and maps standard events, child actors, emitters, and promisees into a unified, flat event map. |
DelayFunction2<E, Pc, Tc, T> |
The signature for delayed transition functions (resolves to a number or a context-aware function returning a number). |
EmitterFunction2<E, Pc, Tc, T, R> |
Type for emitter sources (takes StateExtended and returns a framework-agnostic Pausable<R> instance). |
PredicateS<E, Tc, T> |
Type signature for guard predicate functions (takes a reduced state State<E, Tc, T> and returns boolean). |
CLI
npx @bemedev/app generate [--output path] [--excludes dirs...] [--dry-run]
npx @bemedev/app watch [--output path] [--excludes dirs...]
npx @bemedev/app dev [--output path] [--excludes dirs...] # alias for watch
# Or if installed globally:
app generate [--output path] [--excludes dirs...] [--dry-run]
app watch [--output path] [--excludes dirs...]
app dev [--output path] [--excludes dirs...] # alias for watch
Changelog
Contributing
Contributions are welcome! Please open an issue or pull request on GitHub.
License
MIT
Author
chlbri — bri_lvi@icloud.com