This document outlines the major refinements and new features introduced to the @doeixd/machine library. These changes focus on achieving perfect type safety, zero boilerplate, and universal compatibility.
The primary driver behind these updates is the shift towards Type-State Programming.
In traditional state machine libraries (including earlier versions of this one), states are often treated as data. In Type-State Programming, states are represented as distinct types.
- Impossible States are Unrepresentable: If a property only exists in a
loadingstate, TypeScript will prevent you from accessing it in anidlestate. - Impossible Transitions are Immutable: If a
logintransition only belongs to theunauthenticatedstate, the compiler won't even show you that method when you're in theauthenticatedstate.
A high-performance core optimized for Type-State Programming.
- Rationale: The main library is powerful but can be heavy for simple components. Minimal provides "magic" inference where the entire state machine signature is derived from your implementation.
- Key Primitives:
machine(),factory(),union(),match().
The @doeixd/machine/minimal submodule represents the most advanced expression of Type-State Programming in the library. It strips away the class-like abstractions of the main library to provide a pure, functional, and high-performance core.
This is the foundational unit of the minimal API. It creates a "flat" machine object where the context and transitions live side-by-side.
Why it's better:
- Magic Inference: You don't need to define a
Transitionstype. TypeScript automatically infers the return type of your factory function, mapping it directly onto the machine. - Flat Structure: Transitions are just properties on the object, making it faster and easier to debug.
Allows you to define the logic of a machine once and instantiate it many times.
Why it's better:
- Decoupled Logic: Separate the "how" (transitions) from the "what" (initial data).
- Middleware Support: Factories can be wrapped in middleware (like logging or persistence) that applies to every instance created from them.
The ultimate tool for complex branching states. It eliminates switch and if/else guarded blocks in your machine definitions.
Why it's better:
- Isolated States: Each state gets its own dedicated factory function.
- Recursive
next(): Thenextfunction provided to each branch is union-aware. Transitioning fromidletoloadingcorrectly narrows the machine to theloadingtransitions.
A lightweight utility for consuming machines in your UI or business logic.
Why it's better:
- Total Coverage: Ensures you handled every possible state tag in your union.
- Type-Safe Payloads: Automatically narrows the state data within each match branch.
Extracts the full union of all possible machine states from a union factory.
Why it's better:
- DRY Types: You only define your state mapping once.
UnionOfensures your UI components or helper functions stay in sync with your machine logic perfectly.
The most ergonomic way to define tagged unions.
- Rationale: Writing unions manually (
| { tag: 'a' } | { tag: 'b' }) is repetitive and error-prone.Statesallows you to define a mapping from tags to data objects. - Example:
type AppState = States<{ idle: {}, active: { id: string } }>;
- Rationale: Creating tagged objects manually is tedious.
tag()ensures literal narrowing.tag.factory()provides pre-bound, curried factories that integrate perfectly withStates. - Benefit: No more magic strings in your transitions.
- Rationale: Composition is often the hardest part of state machines.
delegate()allows child machine transitions to be surfaced directly on the parent. - Improvement: We refactored delegation to be "shape-agnostic," meaning it works with both the main library and the minimal module.
- Rationale: Building branching logic within a single factory often leads to complex
if/elseorswitchblocks.union()provides a declarative way to route transitions to specific sub-factories based on the state tag.
You'll notice union<State>()({ ... }) uses two function calls.
- Rationale: This is a TypeScript limitation workaround. TS cannot partially infer generic parameters. By using a "wrapper" call to capture the
Stateunion (union<State>()), we allow the second call to perfectly infer all your transition factories without you ever having to type them manually.
| Function | Use Case | Resulting Object |
|---|---|---|
machine() |
One-off machine, single state shape. | A single Machine instance. |
factory() |
Reusable blueprint, same shape, different initial values. | A function that creates machines. |
union() |
Multi-state blueprint with different shapes per state. | A function that creates narrowed machines. |
The following comparisons show how the new utilities significantly reduce boilerplate while improving type safety.
The States<M> utility converts a flat mapping into a strict tagged union, making the intent much clearer.
```typescript
// ❌ BEFORE: Manual Union Boilerplate
type AuthState =
| { readonly tag: "out" }
| { readonly tag: "in"; user: string; role: 'admin' | 'user' }
| { readonly tag: "error"; message: string };
```
<!-- slide -->
```typescript
// ✅ AFTER: Ergonomic Mapping
type AuthState = States<{
out: {},
in: { user: string; role: 'admin' | 'user' },
error: { message: string }
}>;
```
tag.factory removes the need for magic strings and manual property spreading in your transitions.
```typescript
// ❌ BEFORE: Manual Tagging
login: (user: string) => next({
tag: 'in',
user,
role: 'user'
})
```
<!-- slide -->
```typescript
// ✅ AFTER: Semantic Factories
const authenticated = tag.factory<AuthState>()('in');
login: (user: string) => next(authenticated({
user,
role: 'user'
}))
```
The union factory replaces manual routing logic with a declarative, narrowed structure.
```typescript
// ❌ BEFORE: Manual Routing
const fetch = machine(ctx, (c, next) => {
if (isState(c, 'idle')) {
return { run: () => next(...) };
}
if (isState(c, 'loading')) {
return { stop: () => next(...) };
}
return {};
});
```
<!-- slide -->
```typescript
// ✅ AFTER: Declarative Union
const fetch = union<State>()({
idle: (c, next) => ({
run: () => next(...)
}),
loading: (c, next) => ({
stop: () => next(...)
})
});
```
| Feature | Main Library (@doeixd/machine) |
Minimal API (.../minimal) |
|---|---|---|
| Primary Goal | Feature-rich, established, object-based | High-performance, type-state focused |
| Inference | Strong, but may need hints | "Magic" (Zero manual generics) |
| Boilerplate | Low | Near Zero |
| Performance | Excellent | Optimal (Flat objects) |
| Overhead | Minimal | Zero (Direct function calls) |
| Best For | Complex app-level machines | Component states, local flows |
Here is how the new primitives come together to solve common architectural challenges.
For isolated features with a single data shape, use machine(). It’s low-overhead and perfectly inferred.
import { machine } from '@doeixd/machine/minimal';
const toggle = machine({ on: false }, (ctx, next) => ({
flip: () => next({ on: !ctx.on })
}));
const s1 = toggle.flip(); // on: trueFor complex processes, combine States, tag.factory, and union. This is the most ergonomic and type-safe pattern in the library.
import { union, tag, type States, match } from '@doeixd/machine/minimal';
// 1. Define
type State = States<{
idle: {},
loading: { url: string },
done: { data: string }
}>;
const loading = tag.factory<State>()('loading');
const done = tag.factory<State>()('done');
const idle = tag.factory<State>()('idle');
const flow = union<State>()({
idle: (ctx, next) => ({
start: (url: string) => next(loading({ url }))
}),
loading: (ctx, next) => ({
finish: (data: string) => next(done({ data }))
}),
done: (ctx, next) => ({
reset: () => next(idle({}))
})
});
// 2. Use
let state = flow(idle({}));
state = state.start('/api').finish('result');
// 3. Consume
const message = match(state, {
idle: () => 'Ready',
loading: s => `Loading ${s.url}`,
done: s => `Got: ${s.data}`
});Use delegate() to scale up by composing small, focused machines into a larger parent.
import { machine } from '@doeixd/machine/minimal';
import { delegate } from '@doeixd/machine/delegate';
const child = machine({ count: 0 }, (ctx, next) => ({
inc: () => next({ count: ctx.count + 1 })
}));
const parent = machine({
name: 'App',
child // Parent holds child machine in context
}, (ctx, next) => ({
// Surface child's 'inc' transition directly on the parent
...delegate(ctx, 'child', next)
}));
const result = parent.inc();
console.log(result.child.count); // 1We consolidated the foundations into src/types.ts. This ensures:
- Consistency:
isState(m, 'tag')works regardless of which module you use. - Maintenance: Core tagging logic is in one place.
- Safety: Every utility now supports literal narrowing by default.
This architecture ensures that as the library grows, the core remains lightweight and the specialized submodules remain perfectly compatible.