@bemedev/app-ts
v1.6.3
Published
A reprogramming method to code better
Readme
@bemedev/app-ts
A TypeScript library for creating and managing state machines.
Installation
npm install @bemedev/app-ts
# or
yarn add @bemedev/app-tsFeatures
- Typed state machine creation
- Public and private context management
- Support for actions, guards and delays
- Transition and event handling
- Support for nested machines
- Support for subscribables (e.g rxjs)
- Comprehensive typings utilities with Valibot-like API
Usage
Basic Machine
import { createMachine } from '@bemedev/app-ts';
const machine = createMachine({
initial: 'idle',
states: {
idle: {
on: {
START: 'running',
},
},
running: {
on: {
STOP: 'idle',
},
},
},
});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'),
},
}),
);Machine Interpretation
import { interpret } from '@bemedev/app-ts';
// Create an interpreter service
const service = interpret(machine, {
context: { items: [], error: undefined },
pContext: {}, // private context
});
// Start the service
service.start();
// Send events
service.send('FETCH');
service.send({ type: 'SUCCESS', payload: { data: ['item1', 'item2'] } });
// Get current state
console.log(service.value); // 'success'
console.log(service.context); // { items: ['item1', 'item2'], error: undefined }
// Stop the service
await service[Symbol.asyncDispose]();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();Advanced Machine with Actions, Guards, and Delays
import { createMachine, typings } from '@bemedev/app-ts';
const fetchMachine = createMachine(
{
initial: 'idle',
states: {
idle: {
on: {
FETCH: {
guards: 'canFetch',
target: 'loading',
actions: 'setLoading',
},
},
},
loading: {
entry: 'logEntry',
exit: 'logExit',
promises: {
src: 'fetchData',
then: {
actions: 'handleSuccess',
target: 'success',
},
catch: {
actions: 'handleError',
target: 'error',
},
},
},
success: {
after: {
RESET_DELAY: { target: 'idle', actions: 'reset' },
},
},
error: {
on: {
RETRY: 'loading',
},
},
},
},
typings({
eventsMap: {
FETCH: { url: 'string' },
RETRY: 'primitive',
},
context: {
data: typings.maybe(typings.array('string')),
error: typings.maybe('string'),
loading: 'boolean',
},
pContext: {
retryCount: 'number',
},
promiseesMap: {
fetchData: {
then: typings.array('string'),
catch: { message: 'string' },
},
},
}),
).provideOptions(({ assign, voidAction, isValue }) => ({
actions: {
setLoading: assign('context.loading', () => true),
handleSuccess: assign('context', {
'fetchData::then': ({ payload }) => ({
data: payload,
error: undefined,
loading: false,
}),
}),
handleError: assign('context', {
'fetchData::catch': ({ payload }) => ({
data: undefined,
error: payload.message,
loading: false,
}),
}),
reset: assign('context', () => ({
data: undefined,
error: undefined,
loading: false,
})),
logEntry: voidAction(() => console.log('Entering loading state')),
logExit: voidAction(() => console.log('Exiting loading state')),
},
predicates: {
canFetch: isValue('context.loading', false),
},
promises: {
fetchData: async ({ event }) => {
if (event.type !== 'FETCH') return [];
const response = await fetch(event.payload.url);
return response.json();
},
},
delays: {
RESET_DELAY: 3000, // 3 seconds
},
}));Nested Machines
import { createMachine, typings } from '@bemedev/app-ts';
const childMachine = createMachine(
{
initial: 'step1',
states: {
step1: { on: { NEXT: 'step2' } },
step2: { on: { NEXT: 'step3' } },
step3: { type: 'final' },
},
},
typings({
eventsMap: { NEXT: 'primitive' },
context: { step: 'number' },
}),
);
const parentMachine = createMachine(
{
machines: { child: 'childProcess' },
initial: 'idle',
states: {
idle: {
on: { START: 'working' },
},
working: {
on: { COMPLETE: 'done' },
},
done: {},
},
},
typings({
eventsMap: { START: 'primitive', COMPLETE: 'primitive' },
context: { status: 'string' },
}),
).provideOptions(({ createChild }) => ({
machines: {
childProcess: createChild(
childMachine,
{ context: { step: 0 } },
{ events: 'FULL' },
),
},
}));Activities (Recurring Actions)
import { createMachine, typings } from '@bemedev/app-ts';
const timerMachine = createMachine(
{
initial: 'running',
states: {
running: {
activities: {
TICK_DELAY: 'incrementCounter',
},
on: {
PAUSE: 'paused',
},
},
paused: {
on: {
RESUME: 'running',
},
},
},
},
typings({
eventsMap: { PAUSE: 'primitive', RESUME: 'primitive' },
context: { counter: 'number' },
}),
).provideOptions(({ assign }) => ({
actions: {
incrementCounter: assign(
'context.counter',
({ context }) => context.counter + 1,
),
},
delays: {
TICK_DELAY: 1000, // 1 second
},
}));API Reference
Machine Creation
createMachine(config, types)
Creates a new state machine.
Parameters:
config: Machine configuration objectinitial: Initial state namestates: State definitionsmachines?: Child machine definitionsemitters?: Observable emitters
types: Type definitionscontext: Public context typepContext?: Private context typeeventsMap: Event definitionspromiseesMap?: Promise definitions
Returns: Machine instance
createConfig(config)
Utility to create a typed configuration object.
Machine Methods
machine.provideOptions(callback)
Provides actions, guards, delays, promises, and child machines.
Parameters:
callback: Function receiving helper utilities:assign: Update context valuesvoidAction: Create side-effect actionsisValue,isNotValue: Value comparison guardscreateChild: Create child machine instancessendTo: Send events to child machinesdebounce,throttle: Rate limiting utilities
Returns: Machine instance
machine.addOptions(callback)
Adds or overwrites options dynamically.
Returns: The added options object.
Accessing Legacy Options with _legacy
Both provideOptions and addOptions now support accessing previously
defined options through the _legacy parameter. This allows you to reuse
and compose existing actions, predicates, delays, promises, machines, and
emitters without manual tracking.
Example with Machine:
const machine = createMachine(config, types)
.provideOptions(({ assign }) => ({
actions: {
increment: assign('context', ({ context }) => context + 1),
},
}))
.provideOptions(({ batch }, { _legacy }) => {
// Access previously defined increment action
const previousIncrement = _legacy.actions?.increment;
return {
actions: {
doubleIncrement: batch(previousIncrement!, previousIncrement!),
},
};
});Example with Interpreter:
const service = interpret(machine, { context: 0 });
// Define initial action
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!),
},
}));Example with provideOptions on Interpreter:
// Create new service instances with cumulative options
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 }) => {
// Can access previous multiply action
return context * 2 + 10;
}),
},
}));
// service1, service2, and service3 are independent instancesProperties available in _legacy:
_legacy.actions- Previously defined actions_legacy.predicates- Previously defined guards/predicates_legacy.delays- Previously defined delays_legacy.promises- Previously defined promises_legacy.machines- Previously defined child machines_legacy.emitters- Previously defined emitters
Key features:
- Immutable:
_legacyobject is frozen and cannot be modified - 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)
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
service.value- Current state valueservice.context- Current public contextservice.status- Service status ('idle','working','stopped', etc.)service.state- Complete state snapshotservice.config- Current state configurationservice.mode- Current mode ('strict'|'normal')
Interpreter Methods
service.start()
Starts the service and begins processing.
service.send(event)
Sends an event to the machine.
Parameters:
event: Event name (string) or event object{ type, payload }
service.subscribe(subscriber, options?)
Subscribes to state changes.
Parameters:
subscriber: Callback function or event mapoptions?:id?: Unique subscriber IDequals?: Custom equality function
Returns: Subscription object with unsubscribe() or close() method
service.pause()
Pauses the service (stops activities and timers).
service.resume()
Resumes the service after pausing.
service.stop()
Stops the service completely.
service.addOptions(callback)
Dynamically adds or overwrites options on the running service. Mutates the current service instance.
Parameters:
callback: Same asmachine.provideOptionscallback, including_legacyparameter
Returns: The added options object.
Example:
service.addOptions(({ assign }, { _legacy }) => ({
actions: {
newAction: assign('context', ({ context }) => context + 1),
},
}));service.provideOptions(callback)
Creates a new service instance with additional options. Does not mutate the original service.
Parameters:
callback: Same asmachine.provideOptionscallback, including_legacyparameter
Returns: New Interpreter instance with preserved initial context
Example:
const service2 = service1.provideOptions(({ assign }) => ({
actions: {
customAction: assign('context', ({ context }) => context * 2),
},
}));
// service1 and service2 are independentawait service[Symbol.asyncDispose]()
Cleanly disposes of the service (async).
service.dispose()
Synchronously disposes of the service.
State Configuration
State Definition
states: {
stateName: {
type?: 'atomic' | 'compound' | 'parallel' | 'final',
initial?: string, // For compound states
entry?: ActionConfig, // Actions on entry
exit?: ActionConfig, // Actions on exit
on?: { [event: string]: TransitionConfig }, // Event transitions
after?: { [delay: string]: TransitionConfig }, // Delayed transitions
always?: TransitionConfig, // Automatic transitions
activities?: { [delay: string]: ActionConfig }, // Recurring actions
promises?: PromiseConfig, // Async operations
states?: { [state: string]: StateDefinition }, // Nested states
}
}Transition Configuration
type TransitionConfig =
| string // Target state
| {
target?: string;
guards?: GuardConfig; // Conditions
actions?: ActionConfig; // Actions to execute
}
| TransitionConfig[]; // Multiple transitions (first match wins)Actions
assign(path, updater)
Updates context values.
actions: {
updateCount: assign('context.count', ({ context }) => context.count + 1),
updateMultiple: assign('context', () => ({ count: 0, name: 'New' })),
}voidAction(callback)
Creates side-effect only actions.
actions: {
logState: voidAction(() => console.log('State changed')),
}batch(...actions)
Groups multiple actions.
actions: {
initialize: batch('resetCount', 'clearError', 'logStart'),
}filter(key, predicate)
Filters arrays or objects in context.
actions: {
// Filter array elements
filterEven: filter('context.numbers', (num: number) => num % 2 === 0),
// Filter array of objects
filterActive: filter('context.users', (user) => user.active),
// Filter object properties (by value)
filterHighScores: filter('context.scores', (score) => score >= 80),
}erase(path)
Sets a property to undefined. Uses full decomposed keys like assign.
actions: {
// Erase single property
clearEmail: erase('context.user.email'),
// Erase multiple properties with batch
resetForm: batch(
erase('context.name'),
erase('context.email'),
erase('context.age'),
),
}Guards (Predicates)
isValue(path, value)
Checks if a value equals expected value.
predicates: {
isEmpty: isValue('context.items', []),
}isNotValue(path, value)
Checks if a value differs from expected value.
Custom Guards
predicates: {
isAuthenticated: ({ context }) => context.token !== undefined,
}Delays
delays: {
SHORT: 1000,
LONG: ({ context }) => context.timeout,
}Promises
promises: {
fetchUser: async ({ event, context }) => {
const response = await fetch(`/api/users/${event.payload.id}`);
return response.json();
},
}Typings Utilities
typings.litterals(...values)- Create literal typestypings.union(...types)- Create union typestypings.array(type)- Create array typestypings.tuple(...types)- Create tuple typestypings.any(schema)- Create object schemastypings.record(type, ...keys?)- Create record typestypings.intersection(...types)- Create intersection typestypings.discriminatedUnion(key, ...types)- Create discriminated unionstypings.maybe(type)- Create optional/undefined typestypings.partial(schema)- Make all properties optionaltypings.custom<T>()- Use custom TypeScript typestypings.soa(type)- Single or Array typetypings.sv- StateValue type helperinferT<T>- Infer TypeScript type from typing schema
CHANGE_LOG
NB
Don't use version 0.9.17, it doesn't exports anything
Contributing
Contributions are welcome! Please read our contribution guide for details.
License
MIT
Auteur
chlbri ([email protected])
