Add guards to transitions
Guards are async predicates on transitions. If a guard returns false, the transition does not fire and the instance state is unchanged.
Guard evaluation sequence
- Payload validated against the action's Zod schema (throws
ZodErroron failure). - All transitions whose
fromstate isactiveand whoseonmatches the action are collected. - Each transition's guard is evaluated.
- Transitions whose guard returns
truefire; the rest are skipped. - If no transitions fired, dispatch returns
{ success: false, reason: 'guard-failed' }.
Guard.inject — for I/O-dependent checks
Use Guard.inject when the guard implementation depends on your service layer (database, auth, feature flags). The implementation is supplied at runtime via injectGuard.
// In the workflow definition — no I/O here
.addTransition({
from: 'pending-approval',
to: 'approved',
on: 'APPROVE',
guard: Guard.inject('isManager'),
})
// At runtime — wire the implementation
inst.injectGuard('isManager', async (ctx) => {
return myAuthService.hasRole(ctx.payload.approverId, 'manager');
});Dispatching without injecting throws immediately:
Error: Guard "isManager" has not been injected. Call instance.injectGuard("isManager", fn).Inline guard — for pure checks
Use an inline function when the guard is a pure expression with no external dependencies.
.addTransition({
from: 'safety-walk-done',
to: 'systems-active',
on: 'ACTIVATE_SYSTEMS',
guard: (ctx) => ctx.payload.allOnline === true,
})The inline function receives a GuardContext with:
ctx.payload— the validated action payloadctx.instanceState— read-only view of all state statuses
Guard.fn — explicit wrapper
Guard.fn is the explicit equivalent of an inline function. Use it when you want the typed generic parameter:
Guard.fn<{ role: string }>((ctx) => ctx.payload.role === 'admin');Guard.stateCompleted / Guard.stateActive
Pre-built guards that inspect live instance state:
// Allow APPROVE only after legal-review has completed
guard: Guard.stateCompleted('legal-review');
// Allow ESCALATE only while incident-triage is still active
guard: Guard.stateActive('incident-triage');Guard.and / Guard.or / Guard.not — composition
All guards implement IGuard and compose arbitrarily:
// All conditions must pass
guard: Guard.and([
Guard.inject('isManager'),
Guard.stateCompleted('legal-review'),
Guard.not(Guard.inject('isOnLeave')),
]);
// At least one must pass
guard: Guard.or([Guard.inject('isSupervisor'), Guard.inject('isAdmin')]);
// Invert any guard
guard: Guard.not(Guard.inject('isBlocked'));Guard.always / Guard.never
Useful in tests:
Guard.always(); // always returns true
Guard.never(); // always returns falseMultiple transitions on the same action
Attach multiple transitions from the same state on the same action, each with a different guard. The engine applies all transitions whose guard passes — use complementary guards to enforce mutual exclusion:
.addTransition({ from: 's', to: 'a', on: 'DECIDE', guard: Guard.inject('isApprover') })
.addTransition({ from: 's', to: 'b', on: 'DECIDE', guard: Guard.not(Guard.inject('isApprover')) })
// Exactly one fires — the complementary pair guarantees itGuards are not persisted
Guard functions are runtime behaviour. They are never included in getSnapshot(). After every restoreInstance, re-inject any named guards before dispatching:
const inst = workflow.restoreInstance(snapshot);
inst.injectGuard('isManager', myGuardFn);