Explaining Results
explain() evaluates an expression and records the value of every sub-expression. Use it to show why a rule passed or failed: in a support tool, an audit log, or next to the rule in an editor.
import { bonsai } from 'bonsai-js'
const env = bonsai()
const rule = env.compile('user.age >= 18 && user.plan == "pro"')
const explanation = rule.explainSync({ user: { age: 25, plan: 'free' } })
explanation.ok // => true
explanation.ok ? explanation.value : explanation.error // => false
String(explanation)
// user.age >= 18 && user.plan == "pro" → false
// ├─ user.age >= 18 → true
// │ └─ user.age → 25
// └─ user.plan == "pro" → false
// └─ user.plan → "free"String(explanation) is for logs and terminals: each line shows a sub-expression's source on one line (whitespace collapsed, cut at 60 characters, then ...) and a preview of its value (cut at 120 characters, then ...). For the full text, use trace below.
Reasons
explanation.reasons() returns only the conditions that decided the result: it follows && and || down to the comparisons and values (or the error) behind the outcome. It stops at anything else: a ?:, a let, or a call that takes a lambda (some, every, filter, ...) is reported whole, so look in trace (its children, or iterations for a lambda) for what decided it:
import { bonsai } from 'bonsai-js'
const cart = { total: 50, items: [{ price: 10 }, { price: 40 }] }
const rule = bonsai().compile('cart.total > 100 || cart.items.some(.price > 100)')
rule.explainSync({ cart }).reasons().map((r) => r.text) // => ["cart.total > 100", "cart.items.some(.price > 100)"]A negated condition is a reason as written, so its value reads like every other reason's: for !("vip" in customer.tags) && order.total > 10 with a VIP customer, the reason is !("vip" in customer.tags) with the value false. A ! over && or || (!(a || b)) is followed to the part that decided it, whose value is the opposite of what the rule needed, and !!x reads as x.
import { bonsai } from 'bonsai-js'
const rule = bonsai().compile('!("vip" in customer.tags) && order.total > 10')
const context = { customer: { tags: ['vip'] }, order: { total: 50 } }
rule.explainSync(context).reasons().map((r) => [r.text, r.value]) // => [["!(\"vip\" in customer.tags)", false]]The same condition can appear more than once when several sub-expressions reach it (with exhaustive, for example); de-duplicate by id when listing reasons in a UI.
Explaining a partial evaluation residual explains the residual: its reasons are the conditions left after the known data decided the rest, and known values appear as the __known names of their bindings. To show why a user got a result, explain the full program with the full context.
By default && stops at the first false condition, so there is one reason. Pass exhaustive: true to evaluate the remaining conditions for the explanation, and every failing one is listed. Those parts are marked extra: true, and an error in them (a failing comparison, a throwing getter) is ignored, so the result does not change. The extra work spends from the same step and time budget as the rest, so a limit reached there (STEP_LIMIT, TIMEOUT, a size limit such as STRING_LIMIT) fails the explanation, even when evaluate alone would finish inside the budget. Host functions on those parts do run.
import { bonsai } from 'bonsai-js'
const rule = bonsai().compile('user.age >= 18 && user.plan == "pro" && user.verified')
const user = { age: 16, plan: 'free', verified: true }
rule.explainSync({ user }).reasons().map((r) => r.text) // => ["user.age >= 18"]
rule.explainSync({ user }, { exhaustive: true }).reasons().map((r) => r.text) // => ["user.age >= 18", "user.plan == \"pro\""]The trace
explanation.trace is plain data, ready to send to a UI:
| Field | Meaning |
|---|---|
id | Stable id of the syntax node, shared by every run of it (for example in each iteration) |
kind | Syntax node type: Binary, Member, Call, ... |
operator | For Binary and Unary nodes |
text, start, end | The source text of the sub-expression (at most 120 characters, then ...) and its offsets, to highlight it; slice the source with start and end for the full text, which includes the node's own parentheses ((a == 1) in !(a == 1)) |
evaluated | false when short-circuiting skipped it (false && x, the untaken branch of ?:) |
value | What it produced |
error | { code, message } when it failed |
children | Its sub-expressions, in source order |
extra | true when it was evaluated only because of exhaustive |
iterations | For calls that take a lambda: { index, item, result or error, trace } for each item the lambda ran on (reduce adds accumulator) |
omittedIterations | Lambda runs beyond maxIterations, counted but not recorded |
Live values
Values in trace are the live values from your context, not copies. The trace is typed read-only but not frozen at run time (a trace can hold thousands of records, and freezing each would cost more than recording it), so treat it as read-only. The explanation's own error has the same JSON form as JSON.stringify(error) (see Errors); an error recorded on a trace node is the shorter { code, message }, since its node already gives the span.
Serializing
To send an explanation to a browser or store it, use JSON.stringify(explanation) (or explanation.toJSON()). The JSON is bounded and cycle-safe:
- values become copies, at most 10,000 values in total, however often a value recurs;
- getters are never run, host collections such as typed arrays and
Mapare summarized by type, and a value that throws when read becomes"[unreadable]"; - a map shows its first 50 keys and how many more it has, and is listed once per explanation however often it recurs, so serializing never grows with a map's size. Listing a map counts one value per 50 keys it has toward the 10,000, so a very large map can leave later values as
"...".
JSON.stringify(explanation.trace), any single trace or lambda run, and any list of them you pick out yourself (reasons().filter(...), [...reasons()], trace.children, trace.iterations) serialize as their parts of JSON.stringify(explanation), so they stay within its bound. JSON.stringify(explanation.reasons()) serializes the reasons together under a budget of their own, and String(explanation) shares one budget across the whole rendering.
Stability of the snapshot
The JSON form is taken the first time any part of the explanation is serialized and reused afterwards, so it is the same on every serialization, map listings included, even if your data changes later. It is frozen: copy what toJSON() returns before trimming or editing it.
What the JSON records
The JSON holds the whole value of every node it records: a variable records the whole object passed for it, so subject.id == resource.owner records every field of subject, including ones the rule never reads (tokens, personal data). For audit logs, pass only the fields a rule needs, or log JSON.stringify(explanation.reasons()), which records only the deciding conditions (and the values beneath them).
Errors
explain() never rejects for evaluation errors (and explainSync() never throws for them). It returns { ok: false, error } and marks the node that failed, so you can show where a rule broke (a result refused after evaluation, such as a promise-like value from explain() or a failed expect check, is marked on the root); a context that fails validateContext is returned the same way, as an INVALID_CONTEXT error. Invalid options and a context that is not an object are your own mistakes, and a context Proxy whose traps throw when the context is first inspected fails with HOST_ERROR before evaluation starts: explain() rejects and explainSync() throws, as evaluate() and evaluateSync() do:
import { bonsai } from 'bonsai-js'
const explanation = bonsai().explainSync('stats.visits / stats.days', { stats: { visits: 10, days: 0 } })
explanation.ok // => false
String(explanation)
// stats.visits / stats.days → error DIVISION_BY_ZERO: Division by zero
// ├─ stats.visits → 10
// └─ stats.days → 0Syntax and check errors reject from env.explain(source) and throw from env.explainSync(source), as they do for env.evaluate and env.evaluateSync.
Options
explain(context, options) accepts the evaluation options (timeout, maxSteps, signal, now, validateContext) plus:
| Option | Default | Effect |
|---|---|---|
maxIterations | 20 | Lambda runs recorded per call |
maxTraceNodes | 10,000 | Sub-expressions recorded in total; the result stays exact and truncated is set |
exhaustive | false | Evaluate every condition of && and ` |
Like evaluate() and evaluateSync(), explain() returns a promise (and supports async host functions) and explainSync() returns the explanation directly; explainSync() on an expression that calls an async host function returns an ASYNC_IN_SYNC error. Explaining uses a separately compiled program, so evaluating the same program normally is not slowed down.