Expressions
A condition in a flow is JSON. It has to be: a flow that arrives over the network cannot carry
a function, and a flow stored in a database cannot carry a closure. So when, guards and
every other predicate are written in a small expression language with thirteen operators and
no escape hatch except a named resolver you register yourself.
// The company step is part of the flow only while the payer is a business.const company = { when: { $eq: [{ $get: 'data.payer' }, 'business'] } };Anything that is not an object with a $-prefixed key is a literal - null, booleans,
numbers, strings and arrays evaluate to themselves.
The operators
Section titled “The operators”| Operator | Form | Result |
|---|---|---|
$get |
{ $get: 'data.email' } |
Reads a dotted path out of the scope. |
$ref |
{ $ref: 'needsEmail', args? } |
Calls a resolver you registered under that name. |
$not |
{ $not: e } |
Negation. |
$and |
{ $and: [a, b, ...] } |
Every operand holds. |
$or |
{ $or: [a, b, ...] } |
Some operand holds. |
$eq |
{ $eq: [a, b] } |
===. |
$ne |
{ $ne: [a, b] } |
!==. |
$gt |
{ $gt: [a, b] } |
a > b. |
$gte |
{ $gte: [a, b] } |
a >= b. |
$lt |
{ $lt: [a, b] } |
a < b. |
$lte |
{ $lte: [a, b] } |
a <= b. |
$in |
{ $in: [needle, haystack] } |
Membership. The needle comes first. |
$empty |
{ $empty: e } |
True for null, undefined, '', NaN, an empty array or object. |
That is the entire language. There is no arithmetic, no string manipulation and no way to
define a new operator - anything else is a $ref into code you control, which keeps the
untrusted half of a definition small enough to reason about.
Each operand has the shape in the table: $get and $ref a string, $and and $or a list, the
comparisons and $in a list of two, and $not and $empty any expression. Given anything else -
{ $and: null }, { $eq: [1] }, { $get: 123 } - evaluate and evaluateAsync throw
expr-invalid-operand instead of failing inside with a bare
TypeError, and validateFlow reports the same code before the flow runs.
Where an expression throws decides what the engine does with it. A when, a step’s or a
transition’s, is read as false and the step is left out, with
when-threw logged once - reachability runs over every step on every
move and on every snapshot, so one that threw would otherwise stop the whole wizard. A guard throws
to the caller of the move it decides, unless that move was overtaken or cancelled by then. A
repeat.over is read as no items and an input as undefined.
An expression nests at most 256 levels, counting every object and every list on the way down:
{ $not: e } is one level above e, and { $and: [e] } two, the object and its list. Nothing
written by hand or by a generator comes near that. The limit is there for a pasted or hostile
document, which would otherwise overflow the call stack with a bare RangeError; past it,
evaluate and evaluateAsync throw expr-too-deep and
validateFlow reports the same code. The evaluator throws only for a branch it reaches, since
$and and $or stop early; validateFlow reads the structure and reports an expression past the
limit whatever the data.
The scope
Section titled “The scope”Paths read from three roots and nothing else:
data- what the user has entered.ctx- what the application supplied when the wizard was created.loop- inside a repeat group only:{ index, item, key }.
{ $get: 'data.address.city' } walks the dots. A path that does not resolve yields
undefined rather than throwing, which is why $empty exists.
Resolvers
Section titled “Resolvers”$ref names a function in the registry passed to createWizard. It is how a predicate reaches
code: a validator, a permission check, a price lookup. This is the registry of the flow
Getting started builds, with the one validator it names:
import type { AsyncRegistry } from '@wizzard-packages/core';
/** * The code the flow names. A validator answers with the messages it found, * keyed by field, or `null` when the step may be left. * * Not a boolean: `false` carries no message to show and does not stop the * move. Only an object with at least one key refuses it. */export const registry: AsyncRegistry = { needsEmail: (_args, { data }) => { const details = data['details'] as { email?: string } | undefined; return details?.email ? null : { email: 'Enter an email address.' }; },};The registry is passed to createWizard({ flow, registry }) beside the flow, and it is the only
place a $ref is looked up.
A resolver takes the args from the $ref and the scope, in that order:
(args: Json | undefined, scope: Scope, signal?: AbortSignal) => unknown. Most ignore the first.
What it should return depends on where it is named. In a when its answer is read as true or
false. In a validate it is not: a validator returns the messages it found, keyed by field, or
null, and a false there lets the step be left - see
Validation.
Only a step’s load is handed the third, the signal of the move it runs in; see
Loading a step.
Two evaluators exist because two situations exist. evaluate is synchronous and throws
a resolver-is-async error if a resolver hands back a promise; evaluateAsync awaits it and short-circuits
$and and $or in order. test and testAsync wrap them and coerce to a boolean, treating
an absent expression as true - which is what makes when optional. isSync(expr) reports
whether an expression contains any $ref at all, and so whether it can be evaluated during
rendering.
Writing them in TypeScript
Section titled “Writing them in TypeScript”Hand-written JSON has no type checking and a typo in 'data.payr' is silent. The builder at
@wizzard-packages/core/expr produces exactly the same objects with the paths typed:
import * as e from '@wizzard-packages/core/expr';
const businessPayer = e.eq(e.get('data.payer'), 'business');const eligible = e.and(businessPayer, e.isIn(e.get('data.country'), ['DE', 'FR']));get, ref, not, and, or, empty, eq, ne, gt, gte, lt, lte and isIn -
one per operator, named for it. Only isIn differs from its operator, because in is a
reserved word.
Import it as a namespace. The names are the shortest words in the language - get, and,
or, not, eq - and pulled in one by one they collide with a local variable, with a utility
library, and with each other across files. Behind e. they stay together and the condition
reads as the language it is. Named imports work exactly the same way, and a flow that only ever
writes one condition is welcome to use them.
The builder is a separate entry point, so importing it never pulls the runtime into a bundle that only needed to construct a flow, and constructing a flow never pulls in the builder.