@bemedev/app-ts
v3.0.2
Published
A reprogramming method to code better
Readme
@bemedev/app-ts
[!CAUTION] Do not use version 2.1.0. This version contains build> configuration issues (
rolldown.config.ts) and should be avoided. Please use version 2.2.0 or higher.
A TypeScript library for building finite state machines with a rich, type-safe API. Manages states, transitions, context, asynchronous operations, and reactive streams through a unified actors model.
Philosophy
The machine defines what can happen. The interpreter makes it happen.
A machine is purely declarative: it describes states, transitions, actions,
and guards by name ('fetchUser', 'canSubmit'). It never calls
external code directly. You wire real implementations in later via
provideOptions or addOptions.
The interpreter takes a machine and executes it at runtime — it processes events, manages context, subscribes to actors, and drives transitions.
Actors — two kinds of external work
| Actor type | Shape | Trigger | Direction |
| ---------- | ------------------- | ----------------- | -------------------------------- |
| emitters | () => Pausable<T> | State entry | Source → Machine (read-only) |
| children | () => Interpreter | Machine lifecycle | Bidirectional (via sendTo) |
Emitters importance is NOT touched during the flow
This is the single most important architectural choice to understand.
An emitter is a pausable stream source. It produces values on its own schedule. The machine only reacts to those values — it never sends events to the emitter, never modifies it, never controls its output.
┌─────────────┐ emissions ┌──────────────┐
│ Pausable │ ─────────────► │ Machine │
│ (emitter) │ │ next/error/ │
│ │ ◄── nothing │ complete │
└─────────────┘ └──────────────┘
▲ │
│ subscribe on entry │ stop on exit
└──────────────────────────────┘Emitter lifecycle:
- The machine config declares an emitter name and its handlers (
next,error,complete). provideOptionswires the name to a factory:() => Pausable<T>.- When the interpreter enters the state → the factory is called, the
Pausableis subscribed and started. - Each emission becomes an internal event routed to the matching handler
(
next→ actions,error→ actions,complete→ actions or target). - The machine never sends events to the
Pausable. It is strictly one-way. - When the interpreter exits the state (or stops) → the
Pausableis stopped. - Re-entering the state creates a new
Pausablefrom scratch.
This differs fundamentally from:
- Children — bidirectional; the parent can
sendTothe child interpreter.
Installation
npm install @bemedev/app-ts
# or
pnpm add @bemedev/app-tsRequirements: Node.js ≥ 22, TypeScript ≥ 5.x
Table of Contents
- Basic machine
- Typings utilities
- Machine interpretation
- Subscribe to state changes
- Actions
- Guards (predicates)
- Transitions: on, after, always
- Activities (recurring actions)
- Actors: emitters
- Actors: children
- Tags
- Legacy options (_legacy)
- API reference
1. Basic Machine
import { createMachine } from '@bemedev/app-ts';
const machine = createMachine({
initial: 'idle',
states: {
idle: {
on: {
START: 'running',
},
},
running: {
on: {
STOP: 'idle',
},
},
},
});The config is pure data — no callbacks, no side-effects. You can serialise it, inspect it, or test it independently from runtime.
2. Typings Utilities
The library provides powerful typing utilities inspired by Valibot for defining complex types:
import { typings, inferT } from '@bemedev/app-ts';
// Literals
const status = typings.litterals('idle', 'pending', 'success', 'error');
type Status = inferT<typeof status>;
// 'idle' | 'pending' | 'success' | 'error'
// Union types
const value = typings.union('string', 'number', 'boolean');
type Value = inferT<typeof value>;
// string | number | boolean
// Arrays
const tags = typings.array('string');
type Tags = inferT<typeof tags>;
// string[]
// Tuples
const coordinates = typings.tuple('number', 'number');
type Coordinates = inferT<typeof coordinates>;
// [number, number]
// Objects
const user = typings.any({
name: 'string',
age: 'number',
email: typings.maybe('string'), // optional field
});
type User = inferT<typeof user>;
// { name: string; age: number; email?: string }
// Records
const config = typings.record('string');
// Record<string, string>
const namedConfig = typings.record('number', 'width', 'height');
// { width: number; height: number }
// Intersection types
const person = typings.intersection(
{ name: 'string', age: 'number' },
{ email: 'string', phone: 'string' },
);
type Person = inferT<typeof person>;
// { name: string; age: number; email: string; phone: string }
// Discriminated unions
const shape = typings.discriminatedUnion(
'type',
{ type: typings.litterals('circle'), radius: 'number' },
{
type: typings.litterals('rectangle'),
width: 'number',
height: 'number',
},
);
// Partial objects
const optionalUser = typings.partial({
name: 'string',
age: 'number',
});
type OptionalUser = inferT<typeof optionalUser>;
// { name?: string; age?: number }
// Custom types
const customType = typings.custom<MyCustomType>();
// Single or Array (SoA)
const singleOrMany = typings.soa('string');
type SingleOrMany = inferT<typeof singleOrMany>;
// string | string[]
// StateValue type helper
const stateValue = typings.sv;
type MyStateValue = inferT<typeof stateValue>;
// StateValueUsing Typings with Machines
import { createMachine, typings } from '@bemedev/app-ts';
const machine = createMachine(
{
initial: 'idle',
states: {
idle: {
on: { FETCH: 'loading' },
},
loading: {
on: { SUCCESS: 'success', ERROR: 'error' },
},
success: {},
error: {},
},
},
typings({
eventsMap: {
FETCH: 'primitive',
SUCCESS: { data: typings.array('string') },
ERROR: { message: 'string' },
},
context: {
items: typings.array('string'),
error: typings.maybe('string'),
},
}),
);3. Machine Interpretation
The interpreter brings a machine to life. It holds context, processes events, and manages actor subscriptions.
import { interpret } from '@bemedev/app-ts';
// Create an interpreter service
const service = interpret(machine, {
context: { items: [], error: undefined },
pContext: {}, // private context (invisible to subscribers)
});
// Start the service
service.start();
// Send events
service.send('FETCH');
service.send({
type: 'SUCCESS',
payload: { data: ['item1', 'item2'] },
});
// Read current state
console.log(service.value); // 'success'
console.log(service.context);
// { items: ['item1', 'item2'], error: undefined }
// Stop the service
await service[Symbol.asyncDispose]();4. Subscribe to State Changes
import { interpret } from '@bemedev/app-ts';
const service = interpret(machine, {
context: { items: [], error: undefined },
});
// Subscribe to all state changes
const subscription = service.subscribe((prevState, currentState) => {
console.log('State changed:', {
from: prevState.value,
to: currentState.value,
});
});
// Subscribe to specific events
const eventSubscription = service.subscribe({
SUCCESS: ({ payload }) => console.log('Success:', payload.data),
ERROR: ({ payload }) => console.log('Error:', payload.message),
else: () => console.log('Other event'),
});
service.start();
// Later: unsubscribe
subscription.unsubscribe();
eventSubscription.close();5. Actions
Actions are side-effects that run during transitions. They are provided by
name in the config and implemented via provideOptions.
5.1 assign
Updates context values using decomposed paths.
.provideOptions(({ assign }) => ({
actions: {
// Update a nested field
updateCount: assign(
'context.count',
({ context }) => context.count + 1,
),
// Replace the entire context
reset: assign('context', () => ({
count: 0,
name: 'New',
})),
// Actor-scoped assign (for emitter payloads)
insertData: assign('context.data', {
'fetch::then': ({ payload, context }) => {
context?.data?.push(...payload);
return context?.data;
},
}),
},
}))5.2 voidAction
Side-effect only — does not modify context.
.provideOptions(({ voidAction }) => ({
actions: {
logState: voidAction(
() => console.log('State changed'),
),
// Actor-scoped void action (e.g. for emitter errors)
signals: voidAction({
'interval::error': ({ payload }) => {
console.warn('Error received:', payload);
},
}),
},
}))5.3 batch
Groups multiple actions into one.
.provideOptions(({ batch, assign, erase }) => ({
actions: {
resetForm: batch(
erase('context.name'),
erase('context.email'),
erase('context.age'),
),
},
}))5.4 filter & erase
filter — filters arrays, object arrays, or records in context:
.provideOptions(({ filter }) => ({
actions: {
// Filter array elements
filterEven: filter(
'context.numbers',
(num: number) => num % 2 === 0,
),
// Filter array of objects
filterActive: filter(
'context.people',
({ active }) => active,
),
// Filter record by value
filterHighScores: filter(
'context.scores',
(score) => score >= 80,
),
},
}))erase — sets a property to undefined:
.provideOptions(({ erase, batch }) => ({
actions: {
clearEmail: erase('context.user.email'),
// Erase multiple with batch
clearAll: batch(
erase('context.name'),
erase('context.email'),
erase('context.age'),
),
},
}))5.5 resend & forceSend
Re-dispatch events from within actions.
resend(event)— sends the event only if the machine is not in a blocked state.forceSend(event)— sends the event regardless of blocked state.
.provideOptions(({ resend, forceSend }) => ({
actions: {
retryFetch: resend('FETCH'),
forceIncrement: forceSend('INCREMENT'),
},
}))5.6 Async actions & errorFn
As of v3.0.0, all action helpers accept async functions directly. The
interpreter's action pipeline is fully async and sequentially awaited.
An optional third errorFn argument handles promise rejections inline:
.provideOptions(({ assign, voidAction }) => ({
actions: {
// Async assign — awaits the promise before updating context
fetchUser: assign<'user', User, FetchError>(
'context.user',
async ({ event }) => (await fetch(`/u/${event.id}`)).json(),
// errorFn: called with the rejection value; its result is merged
(err, state) => ({
context: { ...state.context, error: err.message },
}),
),
// Async void action — errorFn absent → error flows to _addError
logActivity: voidAction(
async ({ context }) => {
await analytics.track(context.userId);
},
),
},
}))When errorFn is absent and the action rejects, the error is routed to the
internal _addError channel — no uncaught rejection.
6. Guards (Predicates)
Guards are pure predicates that decide whether a transition should fire.
.provideOptions(({ isValue, isNotValue }) => ({
predicates: {
// Built-in value check helpers
isEmpty: isValue('context.items', []),
hasToken: isNotValue('context.token', undefined),
// Custom predicate
isAuthenticated: ({ context }) =>
context.token !== undefined,
},
}))Usage in config:
states: {
idle: {
on: {
FETCH: {
guards: 'canFetch', // single guard
target: 'loading',
},
},
always: [
{ guards: 'isEmpty', target: '/empty' },
'/default', // fallback — no guard
],
},
}7. Transitions: on, after, always
on — event-driven transitions
states: {
idle: {
on: {
// Simple target
START: '/running',
// With guard and actions
FETCH: {
guards: 'canFetch',
target: '/loading',
actions: 'setLoading',
},
// Multiple candidates — first matching guard wins
SUBMIT: [
{ guards: 'isValid', target: '/success' },
{ guards: 'hasErrors', target: '/error' },
'/fallback',
],
},
},
}after — delayed transitions
Automatically transition after a named delay. If multiple delays are defined, the shortest one that passes its guard wins.
// Simple delay
const machine = createMachine(
{
initial: 'idle',
states: {
idle: { after: { DELAY: '/active' } },
active: {},
},
},
defaultT,
);
machine.addOptions(() => ({
delays: { DELAY: 1000 },
}));
// After 1 s in 'idle' → automatically transition to 'active'// Multiple delays — shortest wins
const machine2 = createMachine(
{
initial: 'idle',
states: {
idle: {
after: {
DELAY1: '/result1',
DELAY2: '/result2',
},
},
result1: {},
result2: {},
},
},
defaultT,
);
machine2.addOptions(() => ({
delays: { DELAY1: 3000, DELAY2: 2000 },
}));
// DELAY2 (2 s) fires first → goes to result2// Delayed with guard
states: {
idle: {
after: {
DELAY: {
guards: 'returnFalse',
target: '/result1',
},
DELAY2: '/result2',
},
},
}
// DELAY fires first but guard prevents transition
// → DELAY2 winsalways — immediate (eventless) transitions
Evaluated every time the state is entered. First matching guard wins.
const machine = createMachine(
{
initial: 'idle',
states: {
idle: {
always: [
{ guards: 'returnFalse', target: '/result1' },
{ guards: 'returnFalse', target: '/result3' },
'/result2', // fallback — no guard
],
},
result1: {},
result2: {},
result3: {},
},
},
defaultT,
);
// First two guards fail → goes to result28. Activities (Recurring Actions)
An activity is an action executed repeatedly on a named delay while the state is active. Activities support pause, resume, and stop controls.
const machine = createMachine(
{
initial: 'idle',
states: {
idle: {
activities: { DELAY: 'inc' },
on: {
PAUSE: { actions: 'pause' },
RESUME: { actions: 'resume' },
STOP: { actions: 'stop' },
},
},
},
},
typings({
eventsMap: {
PAUSE: 'primitive',
RESUME: 'primitive',
STOP: 'primitive',
},
context: { iterator: 'number' },
}),
).provideOptions(
({ assign, pauseActivity, resumeActivity, stopActivity }) => ({
actions: {
inc: assign(
'context.iterator',
({ context }) => context?.iterator + 1,
),
pause: pauseActivity('/idle::DELAY'),
resume: resumeActivity('/idle::DELAY'),
stop: stopActivity('/idle::DELAY'),
},
delays: { DELAY: 100 },
}),
);The activity inc runs every 100 ms while in idle. Sending PAUSE
freezes it, RESUME restarts it, and STOP terminates it permanently for
that state visit.
9. Actors: Emitters
Key concept — emitters are NEVER touched during the flow.
Emitters are pausable stream sources. The machine subscribes to them on
state entry and only reacts to their emissions. It never sends events
to the Pausable, never modifies it, never controls its output.
9.1 How emitters work
Config — declare the emitter name and its handlers:
actors: { interval: { next: { actions: ['assigN'] }, error: { actions: ['handleError'] }, complete: { actions: ['onComplete'] }, }, }Implementation — provide a
Pausable<T>factory:import { createPausable } from '@bemedev/rx-pausable'; // optional RxJS helper .provideOptions(() => ({ actors: { emitters: { interval: () => createPausable( interval(200).pipe( take(5), map(v => v + 1), map(v => v * 5), ), ), }, }, }))Pausable<T>is a framework-agnostic interface exported by this library. Any object satisfying{ subscribe, start, stop, pause, resume }works.createPausable(from@bemedev/rx-pausable) is a convenience wrapper for RxJS observables — it is not a required dependency.Runtime — the interpreter manages the full lifecycle:
- Enter state → factory called →
subscribe()thenstart() - Each
nextemission → routed tonexthandler (actions/target) - An
erroremission → routed toerrorhandler - A
completeemission → routed tocompletehandler - Exit state (or interpreter stops) →
stop() - Re-enter state → a new
Pausablefrom scratch
- Enter state → factory called →
9.2 Simple emitter — accumulating values
Derived from src/emitters/__tests__/data.ts and simple.test.ts
import { createMachine, typings, interpret } from '@bemedev/app-ts';
import { createPausable } from '@bemedev/rx-pausable';
import { interval, map, take } from 'rxjs';
const machine = createMachine(
{
initial: 'inactive',
actors: {
interval: {
next: { actions: ['assigN'] },
complete: { actions: ['mockCompleteAction'] },
},
},
states: {
inactive: { on: { NEXT: '/active' } },
active: { on: { NEXT: '/inactive' } },
},
},
typings({
context: 'number',
eventsMap: { NEXT: 'primitive' },
actorsMap: {
emitters: {
interval: { next: 'number', error: 'primitive' },
},
},
}),
).provideOptions(({ assign }) => ({
actions: {
assigN: assign('context', {
'interval::next': ({ payload, context }) => notU(context) + payload,
}),
},
actors: {
emitters: {
interval: () =>
createPausable(
interval(200).pipe(
take(5),
map(v => v + 1),
map(v => v * 5),
),
),
},
},
}));
const service = interpret(machine, { context: 0 });
service.start();
// The interval emits autonomously every 200 ms:
// emission 0 → (0+1)*5 = 5 → context: 0 + 5 = 5
// emission 1 → (1+1)*5 = 10 → context: 5 + 10 = 15
// emission 2 → (2+1)*5 = 15 → context: 15 + 15 = 30
// emission 3 → (3+1)*5 = 20 → context: 30 + 20 = 50
// emission 4 → (4+1)*5 = 25 → context: 50 + 25 = 75
//
// The machine NEVER told the interval what to emit.
// It only reacted to each value.9.3 Emitter error handling
Derived from src/emitters/__tests__/error.test.ts
When the source errors, the error handler fires. The machine itself is
not "broken" — it simply routes the error value to the declared actions.
import { createPausable } from '@bemedev/rx-pausable';
import { Subject } from 'rxjs';
const sub = new Subject<number>();
const machine = createMachine(
{
initial: 'idle',
actors: {
interval: {
next: { actions: ['assigN'] },
error: { actions: ['signals'] },
},
},
states: { idle: {} },
},
typings({
actorsMap: {
emitters: {
interval: { next: 'number', error: 'number' },
},
},
context: 'number',
}),
).provideOptions(({ assign, voidAction }) => ({
actors: {
emitters: { interval: () => createPausable(sub) },
},
actions: {
assigN: assign('context', {
'interval::next': ({ payload, context }) => context + payload,
}),
signals: voidAction({
'interval::error': ({ payload }) => {
console.warn('Error received:', payload);
},
}),
},
}));
const service = interpret(machine, { context: 0 });
service.start();
// External code pushes values into the Subject:
sub.next(5); // → context becomes 5
sub.next(3); // → context becomes 8
sub.error(20); // → error handler fires, logs warning
//
// The machine didn't control the Subject.
// It only listened.9.4 State-scoped emitters
Derived from src/emitters/__tests__/children.test.ts
When an emitter is defined inside a specific state (not at root), it only runs while that state is active. Exiting the state unsubscribes; re-entering creates a fresh subscription.
const machine = createMachine(
{
initial: 'inactive',
states: {
inactive: { on: { NEXT: '/active' } },
active: {
on: { NEXT: '/inactive' },
actors: {
interval1: {
next: { actions: ['assigN'] },
complete: { actions: ['mockCompleteAction'] },
},
},
},
},
} /* typings... */,
);Timeline:
[inactive] ──NEXT──► [active]
│ subscribe to interval1
│ ... emissions arrive ...
│
◄──NEXT─── [active]
[inactive] │ unsubscribe from interval1
│
──NEXT──► [active]
│ NEW subscription to interval19.5 Emitters vs Children
| Aspect | Emitters | Children |
| --------------- | --------------------------- | ------------------------------ |
| Direction | Source → Machine only | Bidirectional |
| Cardinality | 0..∞ emissions | Ongoing event exchange |
| Machine control | None — read-only | sendTo sends events to child |
| Subscription | subscribe / unsubscribe | interpret / stop |
| Pause / Resume | Via @bemedev/rx-pausable | Via child interpreter |
10. Actors: Children
A child actor is a nested interpreter. The parent can send events to it
via sendTo, and the child's events can bubble up to the parent via on
handlers. Context can be mapped between parent and child.
10.1 Sending events to a child — sendTo
Derived from src/interpreters/__tests__/children.test.ts
const parent = createMachine(
{
actors: {
child: {
on: {
NEXT: { actions: ['notify'] },
},
},
},
initial: 'idle',
states: {
idle: {
on: {
NEXT: { actions: ['sendChildNext'] },
},
},
},
},
typings({
eventsMap: { NEXT: 'primitive' },
actorsMap: {
children: { child: { NEXT: 'primitive' } },
},
}),
).provideOptions(({ sendTo, voidAction }) => ({
actions: {
notify: voidAction(() => {
notify();
}),
sendChildNext: sendTo(child)(() => ({
to: 'child',
event: 'NEXT',
})),
},
actors: {
children: {
child: () => interpret(child),
},
},
}));When the parent receives NEXT, it forwards it to the child via sendTo.
When the child processes NEXT, the parent's on.NEXT handler fires
notify.
10.2 Context mapping between parent and child
const parent = createMachine(
{
actors: {
child: {
// Map child's entire context → parent.pContext.iterator
contexts: { '.': 'iterator' },
},
},
// ...
},
/* typings */
).provideOptions(() => ({
actors: {
children: {
child: () => interpret(childMachine, { context: 0 }),
},
},
}));When the child's context changes, it is automatically synced to the
parent's private context (pContext) at the mapped key. This is one-way:
the parent reads the child's context but does not write to it.
11. Tags
Tags are metadata labels on states. They allow UI code to query what category the current state belongs to without checking state names directly.
Derived from src/interpreters/__tests__/tags/tags.machine.ts
const machine = createMachine(
{
initial: 'idle',
states: {
idle: {
tags: ['idle'],
on: { NEXT: '/working' },
},
working: {
tags: ['working', 'busy'],
on: { NEXT: '/final', PREV: '/idle' },
},
final: {},
},
},
typings({
eventsMap: { NEXT: 'primitive', PREV: 'primitive' },
}),
);
const service = interpret(machine);
service.start();
service.tags; // ['idle']
service.send('NEXT');
service.tags; // ['working', 'busy']Tags in action callbacks
As of v2.5.0, tag literals are propagated into provideOptions callbacks
as a typed union, enabling narrowing directly inside actions:
const machine = createMachine(
{
initial: 'idle',
states: {
idle: { on: { START: '/working' } },
working: {
tags: ['working', 'busy'],
on: { DONE: '/idle' },
},
},
},
typings({ eventsMap: { START: 'primitive', DONE: 'primitive' } }),
).provideOptions(({ voidAction }) => ({
actions: {
log: voidAction(({ tags }) => {
// tags: "working" | "busy" | undefined — not just string
if (tags === 'busy') {
/* handle busy state */
}
}),
},
}));12. Legacy Options (_legacy)
Both provideOptions and addOptions support accessing previously defined
options through the _legacy parameter. This enables composition of
existing actions, predicates, delays, and actors without manual tracking.
On a Machine
const machine = createMachine(config, types)
.provideOptions(({ assign }) => ({
actions: {
increment: assign('context', ({ context }) => context + 1),
},
}))
.provideOptions(({ batch }, { _legacy }) => ({
actions: {
doubleIncrement: batch(
_legacy.actions.increment!,
_legacy.actions.increment!,
),
},
}));On an Interpreter
const service = interpret(machine, { context: 0 });
service.addOptions(({ assign }) => ({
actions: {
add: assign('context', ({ context }) => context + 5),
},
}));
// Reuse previous action via _legacy
service.addOptions(({ batch }, { _legacy }) => ({
actions: {
addTwice: batch(_legacy.actions.add!, _legacy.actions.add!),
},
}));provideOptions on Interpreter (immutable)
const service1 = interpret(machine, { context: 0 });
const service2 = service1.provideOptions(({ assign }) => ({
actions: {
multiply: assign('context', ({ context }) => context * 2),
},
}));
const service3 = service2.provideOptions(({ assign }, { _legacy }) => ({
actions: {
multiplyAndAdd: assign('context', ({ context }) => context * 2 + 10),
},
}));
// service1, service2, service3 are independent instancesProperties available in _legacy:
| Property | Content |
| -------------------- | ------------------------------- |
| _legacy.actions | Previously defined actions |
| _legacy.predicates | Previously defined guards |
| _legacy.delays | Previously defined delays |
| _legacy.machines | Previously defined child actors |
| _legacy.emitters | Previously defined emitters |
Key features:
- Immutable — the
_legacyobject is frozen; it cannot be mutated. - Cumulative — each call sees options from all previous calls.
- Type-safe — fully typed for IntelliSense support.
- Works with both
addOptions(mutates) andprovideOptions(returns new instance).
13. API Reference
Machine Creation
createMachine(config, types?)
Creates a new state machine.
Parameters:
config— Machine configuration objectinitial— Initial state namestates— State definitionsactors?— Root-level actor declarations (emitters, children)
types— Type definitions (viatypings(...))context— Public context typepContext?— Private context typeeventsMap— Event definitionsactorsMap?— Actor type maps (emitters, children)
Returns: Machine instance
createConfig(config)
Utility to create a typed configuration object without creating a full machine.
Machine Methods
machine.provideOptions(callback)
Provides implementations for actions, guards, delays, actors.
Parameters:
callback(helpers, options)— Function receiving:- helpers —
assign,voidAction,batch,filter,erase,sendTo,resend,forceSend,isValue,isNotValue,pauseActivity,resumeActivity,stopActivity - options —
{ _legacy }containing all previously defined options
- helpers —
Returns: New Machine instance (immutable)
machine.addOptions(callback)
Adds or overwrites options dynamically. Mutates the machine.
Returns: The added options object
Interpreter
interpret(machine, options?)
Creates an interpreter service for a machine.
Parameters:
machine— Machine instanceoptions:context— Initial public contextpContext?— Initial private contextmode?—'strict'|'normal'(default:'strict')exact?— Use exact timing intervals (default:true)
Returns: Interpreter service
Interpreter Properties
| Property | Description |
| ----------------- | --------------------------------------------------- |
| service.value | Current state value (string or nested object) |
| service.context | Current public context |
| service.status | Service status ('idle', 'working', 'stopped') |
| service.state | Complete state snapshot |
| service.config | Current state configuration |
| service.mode | Current mode ('strict' | 'normal') |
| service.tags | Active tags for the current state |
Interpreter Methods
| Method | Description |
| -------------------------------------- | ------------------------------------------------- |
| service.start() | Starts the service and begins processing |
| service.send(event) | Sends an event (string or { type, payload }) |
| service.subscribe(subscriber) | Subscribes to state changes |
| service.pause() | Pauses activities and timers |
| service.resume() | Resumes after pausing |
| service.stop() | Stops the service completely |
| service.addOptions(callback) | Mutates the service with new options |
| service.provideOptions(callback) | Returns a new service with additional options |
| service.dispose() | Synchronously disposes of the service |
| await service[Symbol.asyncDispose]() | Cleanly disposes (async) |
State Configuration
states: {
stateName: {
type?: 'atomic' | 'compound' | 'parallel' | 'final',
initial?: string,
tags?: string[],
entry?: ActionConfig,
exit?: ActionConfig,
on?: { [event: string]: TransitionConfig },
after?: { [delay: string]: TransitionConfig },
always?: TransitionConfig | TransitionConfig[],
activities?: { [delay: string]: ActionConfig },
actors?: { [name: string]: ActorConfig },
states?: { [state: string]: StateDefinition },
}
}Transition Configuration
type TransitionConfig =
| string // Target state
| {
target?: string;
guards?: GuardConfig;
actions?: ActionConfig;
}
| TransitionConfig[]; // Multiple candidates (first match wins)Emitter Types
The library exports a framework-agnostic Pausable<T> interface. Any
object satisfying this shape can be used as an emitter factory return value
— no RxJS dependency required.
import type { Pausable, EmitterObserver } from '@bemedev/app-ts';
// EmitterObserver<R>
type EmitterObserver<R> = {
next: (value: R) => void;
error: (err: any) => void;
complete: () => void;
};
// Pausable<R>
type Pausable<R> = {
subscribe: (observer: EmitterObserver<R>) => void;
start: () => void; // begin consuming the source
stop: () => void; // stop and clean up
pause: () => void; // buffer incoming events
resume: () => void; // replay buffer then resume
};When using RxJS, wrap any
Observable<T>withcreatePausablefrom@bemedev/rx-pausable(a separate, optional package).
Typings Utilities Reference
| Utility | Produces |
| -------------------------------------- | -------------------------------- |
| typings.litterals(...values) | Literal union types |
| typings.union(...types) | Union types |
| typings.array(type) | Array types |
| typings.tuple(...types) | Tuple types |
| typings.any(schema) | Object schemas |
| typings.record(type, ...keys?) | Record types |
| typings.intersection(...types) | Intersection types |
| typings.discriminatedUnion(key, ...) | Discriminated unions |
| typings.maybe(type) | Optional / undefined types |
| typings.partial(schema) | All properties optional |
| typings.custom<T>() | Custom TypeScript types |
| typings.soa(type) | Single or Array type |
| typings.sv | StateValue type helper |
| inferT<T> | Infer TS type from typing schema |
Changelog
NB
Don't use version 0.9.17, it doesn't export anything.
Contributing
Contributions are welcome! Please read our contribution guide for details.
License
MIT
Author
chlbri ([email protected])
