Skip to content

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.

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.

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.

$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.

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.