A small, safe, typed expression language for rules, filters, formulas, and templates that people other than your developers write. Zero dependencies, runs in any modern JavaScript runtime, and never executes JavaScript.
Who it's for: teams whose customers, admins, or operations staff write logic into the product (eligibility and pricing rules, saved filters, formula fields, notification templates) and who need to reject a broken rule when it is saved, explain its result, and run it safely.
import { bonsai, t } from 'bonsai-js'
const env = bonsai({
variables: { user: t.object({ age: t.number(), plan: t.enum('free', 'pro') }) },
})
// Reject a broken rule when it is saved, before it ever runs.
// Its message: Property "agee" does not exist on user; did you mean "age"?
const [problem] = env.check('user.agee >= 18').diagnostics
problem?.suggestion // => "age"
const rule = env.compile('user.age >= 18 && user.plan == "pro"', { expect: t.boolean() })
rule.evaluateSync({ user: { age: 36, plan: 'pro' } }) // => true
// Show the author why it was rejected
const why = rule.explainSync({ user: { age: 16, plan: 'free' } }, { exhaustive: true })
why.reasons().map((r) => r.text) // => ["user.age >= 18", "user.plan == \"pro\""]Try it in the Playground.
- Familiar. JavaScript syntax and JavaScript names:
a.b,?.,??, templates,filter,map,includes,toUpperCase. Every function also works as a method, sosum(xs)andxs.sum()are the same call. - Mistakes are caught when a rule is saved, not in production. Declare your data once with
t:user.agee >= 18is rejected withdid you mean "age"?and an exact source range, anduser.nick.toUpperCase()on an optional field is flagged as possibly null. TypeScript types for the context and result come from the same declaration. - Every result can be explained.
explain()records the value of every sub-expression, andreasons()returns the conditions that decided the result, so a "why was this rejected" screen can listuser.age >= 18anduser.plan == "pro". - Safe by construction, and every evaluation is bounded. No prototype access, no globals, no calling functions found in data, no conversion hooks, no mutation:
user.constructoris rejected when the expression is parsed. A deterministic step budget is on by default and charges each operation for the work it does (including text search, regular expressions, sorting, and time zone math), so three nestedmapcalls over a 1,000-item list stop withSTEP_LIMITwithin milliseconds. Produced strings, lists, and nested values are size-limited ("x".repeat(1000000000)fails withSTRING_LIMIT), andmatches()runs in linear time, so(a+)+$cannot hang. A timeout and anAbortSignalbound time spent waiting on your own host functions. - One rule, three places to run it.
partial()evaluates what it can and returns the rest: knowing only{ user: { plan: "pro" } },user.plan == "pro" && order.total > 100becomesorder.total > 100.bonsai-js/queryturns that into a parameterized SQLWHEREclause (Postgres, SQLite) or a MongoDB filter. - Editor-ready.
bonsai-js/serviceprovides completions (afteruser.it offersageandplan, then the methods that apply), hover types, and diagnostics without evaluating anything. - Fast. Expressions compile to closures once (no
eval, CSP-safe). A typical rule evaluates about 10 million times per second on Node, 1.1x to 2.8x faster than@marcbachmann/cel-json the same workloads (bun run benchreproduces this withbenchmarks/vs-cel.bench.ts).
| Bonsai | cel-js | zen-engine | json-rules-engine | json-logic-js | jsonata | jexl | |
|---|---|---|---|---|---|---|---|
| Rules are | text, JS-like | text, CEL | text and JSON decisions | JSON | JSON | text | text, JS-like |
| Browser size (min+gzip) | 29.0 KB for rules checked on a server; 69.2 KB checking in the browser | 24.3 KB | 4.1 KB JS + 470 KB WASM | 24.3 KB | 2.0 KB | 26.5 KB | 6.1 KB |
| Type check against a schema | Yes, with suggestions | Yes | Yes | No | No | No | No |
| Explains results | Yes | No | Per decision node | Per condition | No | No | No |
| Evaluation budget for untrusted input (as documented) | Yes, on by default | Parse-time limits only | No expression budget | No | No | No | No |
| SQL or MongoDB translation | Yes | No | No | No | No | No | No |
In the browser, Bonsai is about 30 KB for rules checked and compiled on a server and evaluated with bonsai-js/runtime/core, close to cel-js, json-rules-engine, and jsonata, and 57 to 69 KB when the browser checks rules too. The smallest libraries (2 to 8 KB) do not type-check rules or bound their evaluation. Bonsai is also the newest. If rules must also run in Go or Java, CEL or ZEN fit better; if rules are JSON produced by a form builder and size matters most, json-logic-js does. The full comparison covers more libraries, throughput, and the method behind every number.
npm install bonsai-jsUpgrading from 0.x? The migration guide covers every change.
Requires Node.js 22 or newer, current Bun, or a modern browser. The package is ESM; require('bonsai-js') works where Node can load ES modules synchronously (22.12 and newer); for the TypeScript settings that allow it, see Install.
In a browser bundle, evaluating rules that were checked and compiled on a server adds about 29 KB minified and gzipped, from bonsai-js/runtime/core with only the built-in functions your rules call (bonsai-js/runtime, with all of them, is about 43 KB). Checking rules in the browser too adds about 69 KB: the parser, type checker, compiler, and built-in library, with explaining and partial evaluation included; bonsai-js/lite leaves those two out, for about 57 KB. bonsai-js/query and bonsai-js/service are separate entry points; the language service on its own is about 47 KB, since it includes the type checker. Unused modules are tree-shaken: importing only t adds under 1 KB, and all the error classes about 1.6 KB. CI enforces these budgets (bun run check:size).
user.age >= 18 && user.country in ["GB", "IE"] // logic and membership
user.nickname ?? user.firstName // null defaults
user.address?.city // safe navigation
`Hello ${user.name}, you have ${cart.items.length} items` // templates
items.filter(.price > 10).map(.name) // "." is the current item
items.map(.price * .qty).sum() // every function is also a method
orders.filter(o => o.lines.some(.sku == o.promo)) // named lambdas for nesting
let total = items.map(.price).sum(); total > 100 ? total * 0.9 : total
has(user.email) // present, even if null
try(stats.visits / stats.days, 0) // recover from evaluation errors
now() - user.createdAt > days(30) // timestamps and durations
startOfDay(order.placedAt, "Europe/Berlin") // calendar math in time zones
// comments work tooThe full reference is docs/language.md. The rules that matter most:
- There is one absent value,
null. Missing properties and hostundefinedread asnull, sox == nullmeans "missing or null". A key holdingundefinedis absent everywhere:{ a: 1, b: undefined }equals{ a: 1 }, and"b" init is false. ==compares by value (lists and maps deeply). There is no type coercion:1 == "1"isfalseand"a" + 1is an error.- Comparisons with
nullarefalse, sousers.filter(.age >= 18)skips users without an age instead of failing. &&,||,!and?:take booleans (nullcounts as false). Use??for defaults.- Arithmetic that would produce
NaNorInfinityis an error, including division by zero.
import { bonsai } from 'bonsai-js'
const env = bonsai()
env.evaluateSync('price * qty', { price: 3, qty: 4 }) // => 12
await env.evaluate('price * qty', { price: 3, qty: 4 }) // => 12
const program = env.compile('items.filter(.active).length')
const items = [{ active: true }, { active: false }]
program.evaluateSync({ items }) // => 1
program.evaluateSync({ items }, { timeout: 50, maxSteps: 10_000 }) // => 1
await program.evaluate({ items }, { signal: AbortSignal.timeout(1_000) }) // => 1evaluateSync compiles the source once and caches the program; evaluate is needed only for expressions that call async host functions. A signal cancels an evaluation while it waits on async host functions; nothing outside a synchronous run can abort it while it runs, so it is bounded by maxSteps and timeout.
Without declared variables an environment is open: unknown identifiers read the context and are typed any. With variables, the environment is strict: the checker knows their types and rejects any other name. Pass strict: false to let undeclared names read the context as any.
import { bonsai, t } from 'bonsai-js'
const env = bonsai({ variables: { user: t.object({ age: t.number(), nick: t.optional(t.string()) }) } })
const checked = env.check('user.agee > 18')
checked.ok // => false
checked.diagnostics // => [{ code: "UNKNOWN_PROPERTY", message: "Property \"agee\" does not exist on user; did you mean \"age\"?", severity: "error", start: 5, end: 9, span: { start: 5, end: 9 }, position: { line: 1, column: 6 }, suggestion: "age" }]
env.check('user.nick.toUpperCase()').diagnostics.map((d) => `${d.code}: ${d.message}`) // => ["NULLABLE_RECEIVER: The value before .toUpperCase() may be null; use ?.toUpperCase() or ?? to supply a default"]
env.compile('user.age + 1', { expect: t.boolean() }) // throws: CHECKTypes: t.string(), t.number(), t.boolean(), t.null(), t.timestamp(), t.duration(), t.literal(v), t.enum(...values), t.list(T), t.object({...}), t.record(V), t.optional(T), t.union(...), t.any(), t.opaque(name) (a host value expressions can hold but not read), t.never(). Infer<typeof type> gives the TypeScript type.
Already have a Zod, Valibot, ArkType, or JSON Schema schema? fromSchema(User) converts it, and the context is typed as the schema's output. See types from your schemas.
import { bonsai, fn, t } from 'bonsai-js'
const rates = new Map([['EUR', 1.1], ['GBP', 1.3]])
const env = bonsai({
functions: {
hasRole: fn({ params: [t.string()], returns: t.boolean(), call: true, run: (call, role) => (call.context.roles as string[]).includes(role) }),
fxRate: fn({ params: [t.string()], returns: t.number(), async: true, run: async (currency) => rates.get(currency) ?? 1 }),
},
})
await env.evaluate('hasRole("admin") || orders.map(fxRate(.currency) * .amount).sum() < 1000', {
roles: ['editor'],
orders: [{ currency: 'EUR', amount: 100 }],
}) // => true- Parameter and result types are declared with
t;run's argument types are inferred from them. Arguments are validated before your code runs, and results are checked deeply against the declared type: a mismatch is aHOST_CONTRACTerror, whichtry()in an expression cannot catch. Anything your function throws, including aBonsaiError, becomes aHOST_ERROR, whichtry()can recover from. async: truefunctions are awaited byevaluate(), one call at a time.evaluateSync()rejects expressions that call them before any host code runs. A function not declaredasyncthat returns a promise is aHOST_CONTRACTerror.- A host function with the same name as a built-in replaces it for that environment, so new built-ins in future releases never change the meaning of your expressions.
- With
call: true,runfirst receives{ context, signal }: the read-only evaluation context, and anAbortSignalthat aborts when the evaluation times out or is cancelled while waiting on the call (pass it tofetch).withContext<Ctx>()returns a version offnwith the context typed:withContext<Ctx>()({ params: [], returns: t.string(), run: (call) => call.context.tenant.id }). - Optional parameters (after
required) arrive asnullwhen omitted, so declare them witht.optional(...). - Each call is charged
coststeps (default 32) against the step budget; set a lowercostfor cheap pure helpers. - Bundle functions (and variables) into a
Library({ name, functions, variables } satisfies Library) and passlibraries: [a, b]; its variables join the context type, and a name defined twice is an error.env.extend({...})derives a new environment.
Context data is not validated against the declared types by default (it is your data, and validation costs time proportional to it). Pass validateContext: true to check it before every evaluation; a mismatch fails with INVALID_CONTEXT and the exact path, e.g. user.created should be timestamp, got string "2026-01-01". Objects may carry more keys than their declared type lists (a database row with extra columns is fine); the checker accounts for that.
Every error thrown while checking or evaluating an expression is a BonsaiError with a stable code, the source, a span ({ start, end }), a 1-based position, and a formatted message with a code frame. That includes failures in your own code: a host function that throws, or a getter or Proxy in the context that throws when read, becomes a HOST_ERROR with the original error as its cause. Invalid configuration passed to bonsai() or fn(), and a syntax tree print() cannot print faithfully, throw a TypeError or RangeError immediately; invalid per-evaluation options do too from evaluateSync(), and make evaluate() reject.
| Class | Codes |
|---|---|
BonsaiSyntaxError |
SYNTAX |
BonsaiCheckError |
CHECK (see .diagnostics) |
BonsaiLimitError |
SOURCE_TOO_LONG TOO_DEEP TOO_MANY_NODES TOO_COMPLEX STEP_LIMIT STRING_LIMIT LIST_LIMIT VALUE_DEPTH_LIMIT PATTERN_LIMIT TIMEOUT ABORTED |
BonsaiRuntimeError |
TYPE_ERROR NO_OVERLOAD NULL_RECEIVER DIVISION_BY_ZERO NON_FINITE BLOCKED_PROPERTY INVALID_ARGUMENT INVALID_CONTEXT ASYNC_IN_SYNC HOST_ERROR HOST_CONTRACT |
BonsaiTranslationError |
UNTRANSLATABLE (thrown by toSQL and toMongo) |
try(expr, fallback) in an expression catches runtime errors except HOST_CONTRACT; it never catches syntax, check, or limit errors.
Every limit except timeout is on by default; limits changes the budget. 0 disables maxSteps and timeout; the other limits must be positive integers.
| Limit | Default | Bounds |
|---|---|---|
maxSourceLength |
100,000 | expression length |
maxDepth |
128 | syntax nesting depth |
maxNodes |
20,000 | syntax tree size, and the work of checking and compiling |
maxSteps |
1,000,000 | work per evaluation, charged by the real cost of each operation |
maxStringLength |
100,000 | strings an expression produces |
maxListLength |
100,000 | lists an expression produces |
maxValueDepth |
64 | nesting of values an expression builds or walks (cyclic data fails closed) |
maxPatternLength |
4,096 | regular expression patterns passed to matches |
timeout |
none | wall-clock milliseconds per evaluation |
The step budget is deterministic: the same expression over the same data uses the same steps on every machine, and at the default budget any expression finishes or fails within about 100 ms on Node (about 150 ms for the slowest shapes we have found). Host functions are trusted code: limits cannot interrupt a synchronous host function that is already running, and a getter or Proxy you put in the context runs when it is read. Set a timeout when expressions call slow host functions. See SECURITY.md and docs/threat-model.md.
All callable as f(x, ...) or x.f(...).
| Area | Functions |
|---|---|
| Text | toUpperCase toLowerCase trim trimStart trimEnd startsWith endsWith includes indexOf lastIndexOf slice split replace replaceAll padStart padEnd repeat at matches toString toNumber |
| Lists | map filter find findIndex some every none count flatMap reduce sort sortBy groupBy reverse unique flat first last join slice at includes indexOf lastIndexOf isEmpty min max sum avg |
| Numbers | round floor ceil trunc abs sqrt clamp toFixed formatNumber formatCurrency min max sum avg |
| Maps and values | keys values entries isEmpty type toString |
| Time | now timestamp duration weeks days hours minutes seconds milliseconds inDays inHours inMinutes inSeconds inMilliseconds year month day hour minute second dayOfWeek startOfDay startOfMonth startOfYear addDays addMonths addYears formatDate |
Signatures and examples: Built-in Functions.
matches(text, pattern) uses JavaScript regular expression syntax (as with the u flag) and runs in linear time, so a user-written pattern cannot hang evaluation; backreferences, lookaround, named groups, \p{...}, and inline flags other than a leading (?i) are rejected (full list), a leading (?i) ignores ASCII case, and a pattern longer than maxPatternLength is a PATTERN_LIMIT error. formatDate understands yyyy yy MMMM MMM MM M dd d EEEE EEE HH H hh h a mm m ss s SSS and rejects unknown letters; constant patterns are checked when the expression is compiled.
env.listFunctions() returns every signature and description, for documentation or tooling.
explain() evaluates and records the value of every sub-expression, so you can 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 rule = bonsai().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
console.log(String(explanation))
// user.age >= 18 && user.plan == "pro" → false
// ├─ user.age >= 18 → true
// │ └─ user.age → 25
// └─ user.plan == "pro" → false
// └─ user.plan → "free"explanation.reasons() returns just the conditions that decided the result. By default && stops at the first false condition; pass { exhaustive: true } to evaluate the rest too, so a "why was this rejected" screen can list every reason:
import { bonsai } from 'bonsai-js'
const rule = bonsai().compile('user.age >= 18 && user.plan == "pro"')
const user = { age: 16, plan: 'free' }
rule.explainSync({ user }, { exhaustive: true }).reasons().map((r) => r.text) // => ["user.age >= 18", "user.plan == \"pro\""]explanation.trace is the full tree: each node has an id, kind, text, start/end source offsets, value or error, and children. Parts skipped by short-circuiting are marked evaluated: false, and lambdas record each item they ran on under iterations (up to maxIterations, default 20; maxTraceNodes caps the whole trace). JSON.stringify(explanation) gives a bounded, cycle-safe snapshot that never runs getters; it records the whole value of each variable the rule reads, so for audit logs pass only the fields a rule needs (or log JSON.stringify(explanation.reasons()), which is bounded the same way). Evaluation errors are returned (ok: false, error) with the failing node marked, never thrown; invalid options or context throw, as with evaluate(). Like evaluate() and evaluateSync(), explain() returns a promise (and supports async host functions) and explainSync() returns the explanation directly. Explaining uses a separately compiled program, so ordinary evaluation is not slowed down.
partial() evaluates what it can from the data you have and returns either the answer or a simplified expression that needs only the missing data:
import { bonsai } from 'bonsai-js'
const rule = bonsai().compile('user.plan == "pro" && order.total > limits.minTotal')
const limits = { minTotal: 100 }
rule.partial({ user: { plan: 'free' }, limits }) // => { status: "value", value: false }
const result = rule.partial({ user: { plan: 'pro' }, limits })
if (result.status === 'residual') {
result.source // => 'order.total > 100'
result.dependsOn // => ["order.total"]
// The same answer as with all the data:
result.evaluateSync({ order: { total: 150 } }) // => true
}Use it to decide early (can this user ever pass?), to precompute the per-user part of a rule once and evaluate the rest per request, or to push a filter down to where the data lives. Unknowns default to the variables and fields missing from what you pass, and an object you pass is not read whole ({...user}, user.keys(), user == saved), since it may be only part of the object; unknown: ['order', 'user.riskScore'] names the unknowns explicitly and says the rest is complete (unknown wins over a value you did pass).
The result is exact: evaluating the residual with the full data gives the same value or error as evaluating the original. Short primitives are written into the residual; other known values (lists, maps, dates) are kept in result.bindings and referenced by name, and evaluateSync/evaluate on the result supply them. now() and host functions stay in the residual unless you pass now or callHostFunctions: true. When the known data already decides that evaluation fails, the result is { status: 'error', error }.
bonsai-js/query turns a filter written in Bonsai into a SQL WHERE clause (Postgres, SQLite) or a MongoDB filter, so user-defined filters run where the data lives:
import { bonsai } from 'bonsai-js'
import { toMongo, toSQL } from 'bonsai-js/query'
const filter = bonsai().compile('order.status == "paid" && order.total > minTotal')
const columns = { status: 'text', total: 'number' } as const
const query = toSQL(filter, { row: 'order', columns, dialect: 'postgres', known: { minTotal: 100 } })
query.sql // => '((("status" COLLATE "C") = ($1::text COLLATE "C")) AND ("total"::float8 > $2::float8))'
query.params // => ["paid", 100]
toMongo(filter, { row: 'order', fields: columns, known: { minTotal: 100 } }) // => { filter: { $and: [{ status: { $eq: "paid" } }, { total: { $gt: 100 } }] }, options: { collation: { locale: "simple" } } }The query selects exactly the records for which the filter evaluates to true in Bonsai, including null handling (SQL's three-valued logic is made two-valued) and records where the filter would fail (they are excluded). One exception: in Postgres, a record whose arithmetic overflows a double (or whose nonzero product rounds to zero) makes the whole query fail with value out of range, an error rather than a different set of rows, where SQLite excludes the record; keep stored numbers within range to avoid it (see the query guide's caveats). Only declared columns can be queried, values are always parameters, and anything without an exact database equivalent throws a BonsaiTranslationError pointing at it. This is checked by differential tests against real SQLite, Postgres, and a MongoDB query engine.
Check and compile where the full toolchain runs, then evaluate in the browser with bonsai-js/runtime, which does not bundle the checker. toArtifact() turns a compiled program into plain JSON:
import { bonsai, t, toArtifact } from 'bonsai-js'
import { runtime } from 'bonsai-js/runtime'
const variables = { user: t.object({ age: t.number() }) }
// On the server, when the rule is saved.
const artifact = JSON.stringify(toArtifact(bonsai({ variables }).compile('user.age >= 18', { expect: t.boolean() })))
// In the browser.
const rule = runtime({ variables }).load(JSON.parse(artifact), { expect: t.boolean() })
rule.evaluateSync({ user: { age: 36 } }) // => trueAn artifact records the checker's choices that affect results and a digest of the definitions they depend on, so it evaluates exactly as the compiled program does, or a runtime with different definitions refuses it with a BonsaiArtifactError saying why. See Runtime.
env.parse(source) returns a JSON syntax tree with source offsets on every node, and print(tree) turns a tree back into source. Printing is deterministic and round-trips: print(env.parse(print(tree))) is the same text, with the fewest parentheses that keep the meaning. That is what a visual rule builder needs: parse, edit the tree, print, save.
import { bonsai, print } from 'bonsai-js'
const tree = bonsai().parse('trim(name).toUpperCase()')
print(tree) // => 'trim(name).toUpperCase()'
print(tree, { calls: 'method' }) // => 'name.trim().toUpperCase()'
print(tree, { calls: 'function' }) // => 'toUpperCase(trim(name))'f(x) and x.f() produce the same call node, so a builder can show any chain as a list of steps and choose how to print it. env.listFunctions() provides the palette (names, signatures, descriptions), and the language service provides the types at any position for dropdowns. forEachChild walks a tree, mapChildren rewrites one, and env.check(source).typeOf(node) gives the type of any checked node. Comments and spacing are not part of the tree, so printing drops them; to change a saved rule and keep its comments, edit the source at the spans the parser records (nameStart and nameEnd cover a property or function name). See the syntax tree reference.
import { createLanguageService } from 'bonsai-js/service'
const service = createLanguageService(env) // env from the first example
service.complete('orders.filter(.', 15) // { start: 15, end: 15, items: created, paid, total, then methods that take a map, such as keys and type }
service.hover('orders.map(.total)', 2) // { start: 0, end: 6, detail: '{ total: number, paid: boolean, created: timestamp }[]' }
service.diagnostics('user.agee') // same diagnostics as env.checkThe service never evaluates expressions or calls host functions.
Bonsai follows Semantic Versioning. The stability policy says what a minor release may change (new syntax, functions, fields, and union variants, messages, step costs) and what is not public API (error constructors, hand-built host functions).
MIT
