Skip to content
danfry1Public

Latest commit

 

History

217 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

bonsai-js

bonsai-js

npm version npm downloads CI CodeQL bundle size zero dependencies node TypeScript license OpenSSF Best Practices

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.

Why Bonsai

  • Familiar. JavaScript syntax and JavaScript names: a.b, ?., ??, templates, filter, map, includes, toUpperCase. Every function also works as a method, so sum(xs) and xs.sum() are the same call.
  • Mistakes are caught when a rule is saved, not in production. Declare your data once with t: user.agee >= 18 is rejected with did you mean "age"? and an exact source range, and user.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, and reasons() returns the conditions that decided the result, so a "why was this rejected" screen can list user.age >= 18 and user.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.constructor is 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 nested map calls over a 1,000-item list stop with STEP_LIMIT within milliseconds. Produced strings, lists, and nested values are size-limited ("x".repeat(1000000000) fails with STRING_LIMIT), and matches() runs in linear time, so (a+)+$ cannot hang. A timeout and an AbortSignal bound 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 > 100 becomes order.total > 100. bonsai-js/query turns that into a parameterized SQL WHERE clause (Postgres, SQLite) or a MongoDB filter.
  • Editor-ready. bonsai-js/service provides completions (after user. it offers age and plan, 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-js on the same workloads (bun run bench reproduces this with benchmarks/vs-cel.bench.ts).

Compared with other libraries

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.

Install

npm install bonsai-js

Upgrading 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).

The language in one screen

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 too

The full reference is docs/language.md. The rules that matter most:

  • There is one absent value, null. Missing properties and host undefined read as null, so x == null means "missing or null". A key holding undefined is absent everywhere: { a: 1, b: undefined } equals { a: 1 }, and "b" in it is false.
  • == compares by value (lists and maps deeply). There is no type coercion: 1 == "1" is false and "a" + 1 is an error.
  • Comparisons with null are false, so users.filter(.age >= 18) skips users without an age instead of failing.
  • &&, ||, ! and ?: take booleans (null counts as false). Use ?? for defaults.
  • Arithmetic that would produce NaN or Infinity is an error, including division by zero.

Evaluating

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) }) // => 1

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

Checking

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: CHECK

Types: 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.

Host functions

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 a HOST_CONTRACT error, which try() in an expression cannot catch. Anything your function throws, including a BonsaiError, becomes a HOST_ERROR, which try() can recover from.
  • async: true functions are awaited by evaluate(), one call at a time. evaluateSync() rejects expressions that call them before any host code runs. A function not declared async that returns a promise is a HOST_CONTRACT error.
  • 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, run first receives { context, signal }: the read-only evaluation context, and an AbortSignal that aborts when the evaluation times out or is cancelled while waiting on the call (pass it to fetch). withContext<Ctx>() returns a version of fn with the context typed: withContext<Ctx>()({ params: [], returns: t.string(), run: (call) => call.context.tenant.id }).
  • Optional parameters (after required) arrive as null when omitted, so declare them with t.optional(...).
  • Each call is charged cost steps (default 32) against the step budget; set a lower cost for cheap pure helpers.
  • Bundle functions (and variables) into a Library ({ name, functions, variables } satisfies Library) and pass libraries: [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.

Errors

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.

Limits

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.

Built-in functions

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.

Explaining results

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 evaluation

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

Filters in your database

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.

Rules in the browser

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 } }) // => true

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

Syntax trees and visual editors

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.

Editor support

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

The service never evaluates expressions or calls host functions.

Stability

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

License

MIT

Releases

Packages

Used by

Contributors

Languages