Domain State MachinesSupported
Model domain state so that illegal transitions fail to compile. @zmdb/app uses branded (phantom) types: a DraftOrder and a PaidOrder are distinct types even though both are just Order at runtime. Branding erases completely — zero runtime cost beyond the value itself — and you never write an as cast.
Branded states#
import { defineState, transition, type Brand } from '@zmdb/app/state';
interface Order {
id: number;
total: number;
}
const Draft = defineState<'Draft', Order>();
const Paid = defineState<'Paid', Order>();
type DraftOrder = Brand<Order, 'Draft'>;
type PaidOrder = Brand<Order, 'Paid'>;Constructing states (no as)#
States are built through a typed factory that intentionally brands an already typed base value:
const order = Draft.create({ id: 1, total: 10 }); // DraftOrderThere is no State.is: phantom brands cannot be recognized at runtime. Validate unknown input with the existing generated is<T>/assert<T> or decoder for the base shape and any literal discriminant first. That validation does not establish transition history. After the domain authorization decision, call create deliberately; it returns the same value without runtime validation.
Declaring transitions#
transition(from, to, fn) produces a function that only accepts the from state. Applying it to any other state is a compile error, and there is simply no function for an undeclared edge:
const pay = transition(Draft, Paid, o => ({ ...o, paidAt: Date.now() }));
const draft = Draft.create({ id: 1, total: 10 });
const paid = pay(draft); // ✅ PaidOrder
// pay(paid); // ✗ compile error — 'pay' expects a Draft order, not a Paid oneThis makes "pay an already-paid order" or "ship an unpaid order" unrepresentable in code that type-checks.
Design notes#
- Compile-time only. Brands are phantom;
createis an identity at runtime, so a state machine adds 0 bytes to your objects. - No
ason the consumer surface — construction goes throughcreate. (The framework contains one isolated, documented brand-attach boundary internally.) - Granular import:
import { defineState } from '@zmdb/app/state'.