Core API
•9 min read
Data-form definitions
Most Directive definitions can be written two ways: as a function (the original form) or as a plain data object (the "data form"). The data form is purely additive — function definitions keep working unchanged, and every surface accepts either.
Why bother
Three things you cannot do with a function but can do with data:
- See why a constraint fired.
system.explain()renders a per-clause✓/✗breakdown of the predicate against the live facts. - Carry the trigger across a wire. A predicate is JSON-safe, so it survives the devtools transport, web-worker boundaries, and replay archives. A function does not.
- Get free deps. A data predicate is structural — the engine knows which facts it reads without running it. Async constraints lose their explicit-
depsfootgun because a datawhenis always sync, and the engine clears anyasync: trueon the def at registration to make the runtime behavior match.
Migrating from XState guards
XState's cond / guards express the same thing as a Directive data when — a boolean precondition over facts. The shape is different but the translation is mechanical:
| XState | Directive |
|---|---|
cond: (ctx) => ctx.count > 5 | when: { count: { $gt: 5 } } |
guards: { isReady: (ctx) => ctx.ready } | when: { ready: true } |
cond: (ctx) => ctx.phase === "red" | when: { phase: "red" } |
| guards + multiple conditions | when: { $all: [ ... ] } |
| async guards | function when (data form is sync-only) |
If your XState guard reads multiple contexts and computes — use the function form. If it compares facts to values — use the data form.
Quick reference
import { createModule, t } from "@directive-run/core";
createModule("traffic", {
schema: {
facts: {
phase: t.string<"red" | "green">(),
elapsed: t.number(),
label: t.string(),
age: t.number(),
firstName: t.string(),
lastName: t.string(),
},
derivations: { isAdult: t.boolean(), fullName: t.string() },
events: { setStatus: { value: t.string(), name: t.string() } },
requirements: { TRANSITION: { to: t.string() } },
},
// Constraint — declarative boolean trigger.
constraints: {
transition: {
when: { phase: "red", elapsed: { $gte: 30 } },
require: { type: "TRANSITION", to: "green" },
},
},
// Effect — runs when a referenced fact changes AND the predicate holds.
effects: {
blink: {
on: { phase: "red" },
run: () => beep(),
},
},
// Resolver — declarative dedup key.
resolvers: {
transition: {
requirement: "TRANSITION",
key: ["to"],
resolve: async (req) => doTransition(req.to),
},
},
// Event — declarative patch instead of a handler.
events: {
setStatus: {
patch: {
$set: {
phase: { $ref: "value" },
label: { $template: "user ${name}" },
},
},
},
},
// Derivation — predicate (boolean) or template (string).
derive: {
isAdult: { compute: { age: { $gte: 18 } } },
fullName: { compute: { $template: "${firstName} ${lastName}" } },
},
});
FactPredicate — boolean predicates
A predicate is an object whose keys are fact names and whose values are either a literal (equality) or an operator object.
Fact names only. A predicate addresses facts, not derivations. To gate on a derivation, reference the underlying fact the derivation reads, or fall back to a function
when/on. This keeps the deps walker structural and avoids derivation-result/predicate-tree races.
when: { phase: "red", elapsed: { $gte: 30 } }
// ^^^^ equality ^^^^^^^^^^^^^ operator object
Multiple keys are AND-ed. For OR / NOT, use combinators:
when: { $any: [{ phase: "red" }, { phase: "yellow" }] }
when: { $not: { paused: true } }
when: { $all: [
{ phase: "red" },
{ $any: [{ elapsed: { $gte: 30 } }, { manualOverride: true }] },
]}
Operator reference
| Operator | Usable on | Example |
|---|---|---|
$eq | any | { phase: { $eq: "red" } } |
$ne | any | { phase: { $ne: "green" } } |
$in / $nin | any | { phase: { $in: ["red", "yellow"] } } |
$gt, $gte, $lt, $lte | number, bigint, Date, string | { elapsed: { $gte: 30 } } |
$between | orderable | { elapsed: { $between: [30, 120] } } |
$matches | string (RegExp only — use real RegExp instances for flag control) | { name: { $matches: /^J/i } } |
$startsWith | string | { name: { $startsWith: "Ada" } } |
$endsWith | string | { email: { $endsWith: "@example.com" } } |
$contains | string, array, or Set | { tags: { $contains: "admin" } } |
$exists | boolean operand | { token: { $exists: true } } (value is not undefined) |
$changed | effects only | { phase: { $changed: true } } |
$matchesrequires aRegExp. Pass/pattern/flagsdirectly — string operands cannot express flags (case-insensitivity, dotall, multiline) and were never structurally safe against ReDoS, so a non-RegExp operand throws at evaluation.
$containsonSet/Map.$containswalks astring(substring match), an array (element equality, structural), or aSet(native.has()).Mapis not yet supported; iterate to an array if you need to gate onMapmembership.
$existsdiverges from MongoDB. MongoDB's$existstests field presence in the document. Directive's$existstestsvalue !== undefined(sonullcounts as defined).
$eq/$neonSet/Mapfacts. Equality is structural — twoSets with the same members compare equal regardless of insertion order; twoMaps compare equal when they have the same key+value pairs.
Empty list semantics
Combinators and membership operators have well-defined behavior on empty operands — chosen to match MongoDB's query algebra:
| Spec | Result | Note |
|---|---|---|
{ $any: [] } | false | No member to satisfy |
{ $all: [] } | true | Vacuous truth — match MongoDB |
{ $not: {} } | false | Equivalent to $not: true |
{ x: { $in: [] } } | false | No value in empty set |
{ x: { $nin: [] } } | true | Every value is not in empty set |
One operator per object — for two operators on the same fact, use the array form or $all:
// ❌ does not type-check
when: { elapsed: { $gte: 30, $lt: 120 } }
// ✓ array form
when: [
{ fact: "elapsed", op: "$gte", value: 30 },
{ fact: "elapsed", op: "$lt", value: 120 },
]
// ✓ $all
when: { $all: [
{ elapsed: { $gte: 30 } },
{ elapsed: { $lt: 120 } },
]}
Predicate depth limit
Every predicate traversal — evaluation, dependency extraction, and the JSON-safety validator — is capped at 64 levels of structural nesting. Past the cap the runtime dev-warns and bails rather than risking a stack overflow on a cyclic or pathologically deep spec. Legitimate predicates nest far fewer than 64 levels; a deeply nested combinator tree ($all / $any / $not) that approaches the cap should be flattened or split into multiple constraints.
FactTemplate — fact-interpolating strings
A string with ${ident} placeholders. Escape ${ with $${. Unknown keys yield an empty string and dev-warn.
derive: { greeting: { compute: { $template: "Hi ${firstName}!" } } }
events: {
setLabel: {
patch: { $set: { label: { $template: "User ${name} (#${id})" } } },
},
},
constraints: {
notifyLow: {
when: { inventory: { $lt: 5 } },
require: { type: "ALERT", message: { $template: "Inventory low: ${inventory}" } },
},
},
Placeholder keys must match [A-Za-z_][A-Za-z0-9_]*.
KeySelector — declarative resolver dedup
key: ["id"] is equivalent to key: (req) => stableStringify(req.id). The order is the declared order; values are stable-stringified (object keys sorted recursively) so two requirements with the same fields in different orders dedupe to the same key.
resolvers: {
fetcher: {
requirement: "FETCH",
key: ["url", "method"],
resolve: doFetch,
},
},
PatchSpec — declarative event handlers
patch replaces the handler arm of an event for the common case of "set facts from the dispatched payload":
events: {
setStatus: {
patch: {
$set: {
// Literal value
active: true,
// Typed copy from a payload field
userId: { $ref: "id" },
// Interpolated string over the payload
label: { $template: "user ${name}" },
},
},
},
},
Use the function handler arm for anything more involved (conditional writes, derived values, side calls).
Cross-module / namespaced predicates
In a constraint that uses crossModuleDeps, facts arrive as facts.self.* and facts.{dep}.*. The data form mirrors that shape:
when: {
self: { phase: "red" },
auth: { token: { $exists: true } },
}
Inspecting what fired
The introspection payoff:
system.observe((event) => {
if (event.type === "constraint.evaluate" && event.whenExplain) {
console.log(event.whenExplain);
// [
// { path: "phase", op: "$eq", expected: "red", actual: "red", pass: true },
// { path: "elapsed", op: "$gte", expected: 30, actual: 20, pass: false },
// ]
}
});
console.log(system.explain(requirementId));
// Requirement "TRANSITION" (…)
// ├─ Predicate clauses:
// │ ├─ ✓ phase $eq red (actual: red)
// │ └─ ✗ elapsed $gte 30 (actual: 20)
// └─ …
whenSpec is also surfaced on every entry of system.inspect().constraints[] when the constraint's when is a data form — so devtools and any custom inspector can render the predicate tree natively.
Static analysis
Two pure utilities walk a predicate without running it — useful for devtools, codegen, lint rules, and any "which facts does this read" check:
import { extractDeps, extractTemplateKeys } from "@directive-run/core";
extractDeps({ phase: "red", elapsed: { $gte: 30 } });
// → Set { "phase", "elapsed" }
extractDeps({ self: { phase: "red" }, auth: { token: { $exists: true } } });
// → Set { "self.phase", "auth.token" }
extractTemplateKeys({ $template: "${firstName} ${lastName}" });
// → Set { "firstName", "lastName" }
For a stable function reference per predicate, wrap it once with memoizePredicate:
import { memoizePredicate } from "@directive-run/core";
const check = memoizePredicate({ phase: "red", elapsed: { $gte: 30 } });
check({ phase: "red", elapsed: 45 }); // → true
Validating a predicate loaded from JSON
A data predicate is a plain object, so it is tempting to load one from JSON. Most operands survive JSON.stringify / JSON.parse — but RegExp, bigint, Set, and Map operands do not. validatePredicate(spec) throws on exactly those unrehydratable operands — call it after JSON.parse to fail loud rather than silently mis-evaluate:
import { validatePredicate } from "@directive-run/core";
const spec = JSON.parse(stored);
validatePredicate(spec); // throws if a RegExp/bigint/Set/Map operand is present
It is an opt-in helper — the engine does not call it automatically.
Gotchas
A few sharp edges worth knowing once:
async: trueon a datawhenis ignored. A datawhenis always sync — use a functionwhenfor async preconditions.- Explicit
depson a datawhenis ignored. A data predicate carries its own deps (extracted structurally), so anydeps: [...]you add is unused. - Typo'd
$-operators dev-warn.{ elapsed: { $eqq: 30 } }triggers a runtime dev warning naming the typo and the known operators; the malformed clause evaluates tofalse. $changedis effects-only. Constraints have noprevsnapshot, so$changedonly makes sense inside an effecton. Gate a constraint on "fact changed" with a boolean derivation that watches the change source.- One operator per object.
{ $gte: 30, $lt: 120 }does not type-check. Write the array form or$allfor multi-operator predicates on the same fact.
Data vs function vs derivation — decision matrix
Use this matrix to pick the right form:
| Need | Use |
|---|---|
| Single-fact equality/comparison | data when |
Cross-fact comparison (a > b) | derivation + data when on the derived value |
| String prefix/suffix/contains | data when ($startsWith / $endsWith / $contains) |
| Async precondition | function when (data is always sync) |
| Regex flag control | data when with a RegExp literal |
| Conditional branches / complex logic | function when |
| Effect runs on fact change | data on with $changed: true |
| Effect runs on derived value | function on (data on is fact-keyed) |
The two forms compose cleanly — mix them freely in the same module.
Observing rejected writes
When a bound resolver's owned-fact write is dropped because the fact was changed by something else mid-flight, Directive emits a resolver.write.rejected observation event with reason: "clobbered". The event is a discriminated union on kind — branch on it before reading the arm-specific fields:
system.observe((e) => {
if (e.type === "resolver.write.rejected") {
if (e.kind === "summary") {
console.warn(
`[rejected] ${e.resolver}: ${e.dropped} further writes dropped (rate-limited)`,
);
} else {
console.warn(
`[rejected] ${e.resolver} dropped ${e.fact}: ` +
`expected=${JSON.stringify(e.expected)} actual=${JSON.stringify(e.actual)}`,
);
}
}
});
The reason field keeps the event backend-neutral — clobber detection is the in-memory implementation today; future write-rejecting backends can report other reasons under the same event type. The "summary" arm is the per-resolver suppression summary (emitted once after the per-instance cap); fact/expected/actual exist only on the "rejection" arm. Devtools and the logging plugin surface this event by default.

