Partial Evaluation
partial() evaluates an expression with only some of its data. It returns the answer when the data you have already decides it, or a simplified expression (the residual) that needs only the missing data.
import { bonsai, t } from 'bonsai-js'
const env = bonsai({
variables: {
user: t.object({ plan: t.enum('free', 'pro') }),
order: t.object({ total: t.number() }),
limits: t.object({ minTotal: t.number() }),
},
})
const rule = env.compile('user.plan == "pro" && order.total > limits.minTotal')
rule.partial({ user: { plan: 'free' }, limits: { minTotal: 100 } })
// { status: 'value', value: false }
const result = rule.partial({ user: { plan: 'pro' }, limits: { minTotal: 100 } })
result.status // 'residual'
if (result.status === 'residual') {
result.source // 'order.total > 100'
result.dependsOn // ['order.total']
result.evaluateSync({ order: { total: 150 } }) // true
}Uses
- Decide early. A
valueresult means the missing data cannot change the outcome (a free user never passes this rule). - Precompute. Evaluate the per-tenant or per-user part of a rule once, keep the result, and evaluate only the per-request part later. A residual does less work than the original program and evaluates faster.
- Push filters down.
toSQLandtoMongorun this partial evaluation for you: pass the original program with the known values inknown, and the part that reads the record becomes a database query.
Results
status | Meaning |
|---|---|
value | The known data decides the result: value |
error | Evaluation fails whatever the unknown data is: error |
residual | residual (syntax tree), source, bindings, dependsOn (the context paths the residual reads; for x.length, the path of x), hostFunctions (host functions the residual still calls), readsContext (whether one of them is declared call: true, so it reads more of the context than dependsOn lists), and evaluateSync(context, options?) / evaluate(context, options?), which take the same evaluation options as a program (timeout, maxSteps, signal, now); context is typed as any part of the program's context, so a misspelled variable does not compile. explainSync(context, options?) / explain(context, options?) explain the residual the same way: each trace node's text is the residual's own (printed) text, and its offsets refer to the original expression (0 for values filled in from the known data) |
The residual is exact: evaluating it with the full data gives the same value or error as evaluating the original expression with the full data. Simplifications that would change a result (for example turning x && false into false when x could fail) are not made.
Bindings
Short primitives (numbers, booleans, null, strings up to 16 characters) are written into the residual. Other known values, such as longer strings, lists, maps, dates, and durations, are kept in result.bindings (lists and maps as frozen copies, see below) and named in the residual (order.sku in __known1); a value used in several places is bound once. result.evaluateSync(context) and result.evaluate(context) supply the bindings for you; to store a residual instead, see Storing a residual below.
Options
| Option | Default | Effect |
|---|---|---|
unknown | variables and fields missing from known, and objects read whole | Variables or dotted paths (order, user.riskScore) to treat as unknown; unknown wins over a value you passed |
callHostFunctions | false | Call host functions whose inputs are known (only synchronous ones); otherwise they stay in the residual |
now | none | The time now() returns; otherwise now() stays in the residual (the environment's clock is not used, so a stored residual reads the time when it is evaluated) |
maxSteps | the environment's | Step budget for the whole partial evaluation (0 for none) |
timeout | the environment's | Wall-clock budget in milliseconds for the whole partial evaluation (0 for none) |
signal | none | Cancels the partial evaluation with an ABORTED error |
Limit errors (steps, time, cancellation) are thrown from partial() rather than guessed. Every sub-expression evaluated during one partial() call shares one step budget and one deadline. env.partial(source, known, options?) compiles through the environment's cache and does the same. Options follow the same rules as everywhere else: an unknown option or a wrong type is a TypeError. With validateContext, the variables in known are validated as evaluation validates them (a mismatch is an INVALID_CONTEXT result), so a known object must have all its declared fields. To leave part of an object unknown, list its path in unknown (user.riskScore): a variable with an unknown path inside it is not validated.
Details
- Evaluate the residual with the full context. A known part that fails (say, a division by zero in a branch that may not run) stays in the residual so the error can still happen, and it reads the known variables again.
- Known parts of every branch are evaluated, including branches the unknown data may never choose. They count toward the step budget, and context getters they read run during
partial(). - Host functions are called only with
callHostFunctions: true, never when they are async, and functions declaredcall: trueonly when you also passunknown: [], since they can read variables the expression does not name. - Missing data is unknown by default. Without an
unknownlist,partial()decides only from data you passed, at any depth and however the expression reads it. A variable missing fromknownand a field missing from a known object (cart.itemswhenknownhascart: {}) both stay unknown, and their paths appear independsOn. A field is the same path however it is named:cart["items"]iscart.items, andtiers[level]withlevelknown to be"gold"readstiers.gold. A known object read whole (cart == saved,{...cart},cart.keys(),cart.values(),cart[key]withkeyunknown, or aletthat holds it) stays unknown too, in typed and open environments alike: the object you passed may be only part of it (an optional field, or an extra key, may still come), and a whole read would see the difference. A key that holdsundefinedcounts as missing, so it is unknown too (evaluation reads it as null); passnullfor a value that is known to be absent. Lists, dates, durations, and strings are values: a known list is taken as given, andlengthreads it. - Pass an
unknownlist to decide whole reads. It says the rest ofknownis complete, so a known object read whole is decided. - With an explicit
unknownlist, nothing else is guessed: a variable or field that is neither inknownnor listed reads asnull, as it would in normal evaluation. A listed path also says that the objects above it exist: withunknown: ["cart.items"]and nocartinknown, all ofcartis unknown (socart.couponis not read asnull). Whenknowndoes givecart, onlycart.itemsis unknown and its other fields are taken as given. expectstill applies. A program compiled withexpectchecks a decidedvalueagainst it (a mismatch is aTYPE_ERRORresult), and its residual checks results the same way.- The residual reads your context as it is. The bindings are compiled into the residual, so evaluating it copies nothing: variables and getters read your own object. Pass the full context, or at least every path in
dependsOn; a part that always fails stays in the residual and reads the known variables again (see the first point). partial()readsknownwhen it is called; later changes to it are not seen by the residual. The known values a residual keeps (its bindings, and the known data below) are frozen copies taken duringpartial(): lists and maps are copied, a date becomes a newDate, and durations and opaque host values (aMap, aRegExp) are kept as they are. A value whose getter or Proxy trap throws while it is copied is kept as it is, and fails where evaluation reads it. To pick up a changed configuration, callpartial()again. Copying counts toward the step budget.call: truefunctions see the known data too (readsContextistrue). They receive your context ascall.contextwhen it already has everythingknowngave; otherwise they receive a frozen copy of the known data overlaid with your context at every depth (your values win; plain objects on both sides are merged, so passing{ user: { plan } }keeps the knownuser.age). They then see the same data they would see in a full evaluation. Paths listed inunknownare left out of the known data first, so a stale known value there never reaches them: only your context supplies it. Shared and cyclic objects are merged once, at any depth, and a cycle leads back to the merged copy. Building that overlay counts toward the residual's step budget, on every evaluation of a residual withreadsContext, whether or not the call runs.- With
validateContext, a residual validates the context it runs on: the known data (without the paths listed inunknown) overlaid with the context you pass, as above. A context holding exactly the paths independsOnpasses; a value you pass is validated in place of the known one. - Explaining a residual explains the residual. Its trace and
reasons()cover the residual's own conditions, with known values shown as the__knownnames of their bindings; the conditions the known data decided are gone. For a screen that answers "why did this user get this result", explain the full program with the full context instead. - Residual size is limited. Printing the residual counts toward the step budget, and a residual longer than the environment's
maxSourceLengthis aSOURCE_TOO_LONGlimit error thrown frompartial(). - Storing a residual.
result.evaluateSyncandresult.evaluateare the reliable way to run it; keep the result in memory where you can. If you storesourceandbindingsand compile them yourself, use a non-strict environment (the binding names are not declared variables) and pass the bindings in the context. Bindings are values (frozen copies of lists and maps): JSON turns a date or a duration into ISO-8601 text, so restore dates withnew Date(text)(or store bindings in a format that keeps them) before evaluating. Inlined values can also make a type error in an untaken branch visible to the checker.