Installation #

Install Bonsai with your preferred package manager. The package is ESM-only with TypeScript types included.

$ bun add bonsai-js
$ npm install bonsai-js

Then import and start evaluating:

import { bonsai } from 'bonsai-js' const expr = bonsai() const qualifiesForFreeShipping = expr.evaluateSync( 'order.total >= freeShippingThreshold', { order: { total: 120 }, freeShippingThreshold: 100 } ) console.log(qualifiesForFreeShipping) // true
Most applications start with the same pattern: create one shared instance, load only the stdlib modules you need, use evaluateSync() for sync paths, and use compile() for repeated rules.

Quick Start #

This is the default setup pattern for most applications: one shared instance, a small stdlib import, sync evaluation by default, and compiled rules when expressions repeat.

1. Create one instance and load what you need

import { bonsai } from 'bonsai-js' import { strings, arrays, math } from 'bonsai-js/stdlib' const expr = bonsai() .use(strings) .use(arrays) .use(math)

Reuse this instance instead of recreating it. That keeps the cache warm and gives you one place to register custom functions or safety settings.

2. Pass data in through the context object

subtotal + shippingFee 101 { subtotal: 89, shippingFee: 12 }
order.total >= freeShippingThreshold true { order: { total: 129 }, freeShippingThreshold: 100 }
customer.country == "GB" && customer.emailVerified true { customer: { country: "GB", emailVerified: true } }

3. Use pipes for readable transformations

The pipe operator reads left to right. It is the simplest way to express cleanup, filtering, mapping, and formatting work.

customerName |> trim |> upper "ACME LTD" { customerName: " Acme Ltd " }
orders |> filter(.status == "paid") |> map(.id) |> join(", ") "INV-1001, INV-1003" { orders: [{ id: "INV-1001", status: "paid" }, { id: "INV-1002", status: "draft" }, { id: "INV-1003", status: "paid" }] }

4. Compile expressions that repeat

const qualifiesForFreeShipping = expr.compile('order.total >= freeShippingThreshold') qualifiesForFreeShipping.evaluateSync({ order: { total: 129 }, freeShippingThreshold: 100 }) // true qualifiesForFreeShipping.evaluateSync({ order: { total: 49 }, freeShippingThreshold: 100 }) // false
Use evaluate() only when you need async host code. If your transforms and functions are synchronous, evaluateSync() is the simpler and faster default.

Mental Model #

You only need four ideas to work effectively with Bonsai.

Expressions are strings

You store or pass source text like order.total >= freeShippingThreshold. Bonsai parses and evaluates that string for you.

expr.evaluateSync('order.total >= freeShippingThreshold', { order, freeShippingThreshold })

Context is just data

The context object becomes the variable scope for the expression. There is no access to globals or hidden ambient state.

{ order: { total: 129, country: 'GB' }, freeShippingThreshold: 100 }

Pipes are for data flow

Transforms read naturally from left to right, which is why most formatting and collection work is easiest to express with |>.

customerName |> trim |> upper

Compile is a performance tool

If an expression will run many times, compile it once and keep the returned object around.

const rule = expr.compile('order.total >= freeShippingThreshold')
Simple rule of thumb: use transforms for “take this value and do something to it”, and functions for “call a named operation with arguments”.

Literals & Types #

Literals are the values you can write directly inside an expression. Everything else comes from the context object, property access, functions, or transforms.

4242number
"hello world""hello world"string (single or double quotes)
truetrueboolean
nullnull
undefinedundefineduseful with ?? fallbacks
More number formats
3.143.14decimals
1_000_0001000000underscores for readability (ignored)
0xFF255hexadecimal
0b101010binary
1.5e31500scientific notation
Remember: arrays, objects, and template strings have their own syntax sections below. If a value belongs to your application state, pass it in through the context object and reference it by name.

Operators #

Operators cover math, comparison, branching, and membership tests. The rules are close to JavaScript, but == is strict in Bonsai - there is no loose equality.

If you want to...UseExample
Do math+ - * / % **subtotal + shippingFee
Compare values== != < > <= >=order.total >= freeShippingThreshold
Branch or provide defaults?:, ??, ||, &&customer.nickname ?? customer.firstName
Test membershipin, not in"pro" in plans

Arithmetic

basePrice + addOnPrice * seats85{ basePrice: 49, addOnPrice: 12, seats: 3 }* runs before +
2 ** 101024exponentiation

Comparison & equality

order.total >= minimumOrdertrue{ order: { total: 149 }, minimumOrder: 100 }
1 == "1"falsestrict - no type coercion, ever

Conditional logic

Use ?? for nullish defaults, || for falsy defaults, and the ternary operator when you need an explicit if/else branch.

order.total >= freeShippingThreshold ? "free" : "paid""free"return a shipping label directly from the rule
customer.nickname ?? customer.firstName"Alicia"?? falls back only for null or undefined
couponCode || "no-code""no-code"|| also falls back for empty strings and other falsy values

Membership

in and not in work with arrays and strings. They are a good fit for tags, roles, lists, and substring checks.

"pro" in planstrue{ plans: ["starter", "pro", "enterprise"] }
"invoice-" in fileNametruealso works as a substring check on strings
More precedence and edge cases
(subtotal + shippingFee) * taxMultiplier129.6parentheses make billing logic explicit
remainingSeats % seatsPerRow2remainder is useful for layout and batching rules
order.paid && order.fulfilledtrue
!user.suspendedtrueboolean negation
nickname ?? "Guest"""?? preserves empty strings and other defined falsy values
"legacy" not in enabledFeaturestrue
Guideline: when an expression becomes important business logic, add parentheses even when precedence would make it unnecessary. Stored rules are easier to review when grouping is explicit.

Property Access #

Use property access to read nested objects, array elements, and dynamic keys from the context object. Missing values resolve to undefined, which makes ?? defaults very natural.

PatternUse it for
user.nameRead a known property
items[0]Read an array element
record[key]Use a dynamic property name from context
user?.profile?.avatar ?? "default.png"Make the null-tolerant path explicit and provide a fallback

Dot & bracket notation

customer.name"Alicia"
order.items[0].sku"SKU-001"
profile["first-name"]"Alicia"brackets for keys with special characters

Optional chaining

Use ?. when you want to say, directly in the expression, that a missing branch is expected and should quietly produce undefined.

customer?.billing?.countryundefined{ customer: null }no error thrown
customer?.billing?.country ?? "GB""GB"combine with ?? for fallbacks
messages?.[locale] ?? messages?.en"Bonjour"optional chaining also works with computed keys
Security note: allowlists and denylists apply to root identifiers and member names. If user.secret should be blocked, secret must stay out of allowedProperties.

Pipe Operator #

Pipes are how you build readable data flow. They take a value on the left and pass it into a transform on the right, which makes cleanup and collection logic much easier to scan.

Basic pipe

|> passes the left-hand value as the first argument to the transform on the right.

status |> upper "PENDING" { status: "pending" }

Chaining

Each stage receives the previous result, so the expression reads in the same order as the transformation itself.

companyName |> trim |> upper "ACME LABS" { companyName: " Acme Labs " }

Pipes with arguments

The piped value is always the first argument. Any extra arguments go in parentheses after the transform name. When there are no extra arguments, parentheses are optional — name |> trim and name |> trim() are identical.

roleCsv |> split(",") ["admin", "billing", "ops"] { roleCsv: "admin,billing,ops" }
requestedDiscount |> clamp(0, maxDiscount) 25 { requestedDiscount: 35, maxDiscount: 25 }

Complex pipelines

Most real application rules end up looking like this: filter a collection, project the values you care about, then format the result.

orders |> filter(.status == "paid") |> map(.id) |> join(", ") "INV-1001, INV-1003" { orders: [{ id: "INV-1001", status: "paid" }, { id: "INV-1002", status: "draft" }, { id: "INV-1003", status: "paid" }] }
Choose the readable form: if one value is flowing through a sequence of steps, use pipes. If you are combining peer values like min(a, b) or discount(price, pct), a function call is usually clearer.

Collections #

Collections let you build structured results directly inside an expression. Use them when the result should be data, not just a boolean or string.

PatternUse it for
[1, 2, 3]Create a new array value
[...items, extra]Combine iterable values
{ name, age }Build an object from context values
{ [key]: value }Create an object with a computed key
value.slice(0, 3)Call one of the safe built-in methods

Array literals

["starter", "analytics", "priority-support"]["starter", "analytics", "priority-support"]
[plan, ...addons, "priority-support"]["starter", "analytics", "exports", "priority-support"]spread accepts iterable values

Object literals

{ plan: "pro", seats: 5 }{ plan: "pro", seats: 5 }
{ plan, seats }{ plan: "growth", seats: 12 }shorthand - take values from context
{ [metric]: value }{ mrr: 1299 }computed keys are supported

Method calls

Core method-call support is intentionally small. Bonsai allows a safe subset of built-in string, array, and number methods such as slice, includes, startsWith, repeat, trimStart, at, and toFixed.

invoiceId.startsWith("inv_")true
selectedRegions.includes("gb")true
invoiceFile.slice(0, 7)"invoice"
orderTotal.toFixed(2)"129.90"
Methods vs pipes: many string operations are available both as method calls and as pipe transforms (via the stdlib). For example, name.startsWith("inv_") and name |> startsWith("inv_") do the same thing. Use method calls for simple, one-off checks. Use pipes when chaining multiple steps — name |> trim |> lower |> startsWith("inv_") reads more clearly than nested method calls.

Template Literals #

Template literals are for building display strings: messages, labels, emails, filenames, and user-facing output.

`Welcome back, ${user.firstName}`"Welcome back, Alicia"{ user: { firstName: "Alicia" } }
`${cart.items.length} item${cart.items.length == 1 ? "" : "s"} in cart`"3 items in cart"{ cart: { items: [{...}, {...}, {...}] } }
`Plan: ${user?.plan ?? "free"}`"Plan: pro"
Keep the distinction clear: template literals always produce strings. If you need structured output, build an array or object instead of interpolating JSON-like text.

Lambda Predicates #

Lambdas are Bonsai's shorthand for “do this to each item” inside array transforms like map, filter, find, some, and every.

ShorthandMeaningTypical use
.idRead the current item's idorders |> map(.id)
.price < 100Test the current itemproducts |> filter(.price < 100)
.email.endsWith("@acme.com")Use member access and safe method calls on the current itemusers |> some(.email.endsWith("@acme.com"))

Property extraction

A dot prefix like .name means “read this property from each item”. You do not declare a parameter name; the current array item is implied.

orders |> map(.id)["INV-1001", "INV-1002"]
stores |> map(.address.city)["London", "Paris"]

Predicate filtering

Add a condition after the accessor when you want a boolean test for each item.

products |> filter(.price < 100)[{ name: "Keyboard", price: 89 }]
members |> filter(.active && .verified)[{ name: "Alicia", active: true, verified: true }]
More array transforms: find, some, every
orders |> find(.status == "failed"){ id: "INV-1002", status: "failed" }first match
orders |> some(.overdue)trueat least one match
deployments |> every(.successful)falsenot all match

Compound lambdas

Lambdas support nested property access, logic operators, and the same safe method-call surface available elsewhere in the language.

users |> filter(.email.endsWith("@acme.com")) [{ email: "alice@acme.com" }]
Tip: Lambdas only work as arguments to transforms that expect functions. They are shorthand, not general-purpose standalone expressions like JavaScript arrow functions.

bonsai(options?) #

Create one configured evaluator instance and reuse it anywhere you need expression execution, validation, or compiled rules.

import { bonsai } from 'bonsai-js' // Default instance const expr = bonsai() // Restricted instance for user-authored expressions const safe = bonsai({ timeout: 50, maxDepth: 50, maxArrayLength: 10000, allowedProperties: ['user', 'age', 'country', 'plan'] })

Choose the right API

If you need...Use...
Repeated evaluations with custom options, plugins, or cache reusebonsai()
A quick one-off evaluation with default behaviorevaluateExpression()
A reusable hot-path rule objectcompile()
Syntax checks and reference extraction before executionvalidate()
Async transforms or async functionsevaluate()
Lowest-overhead sync executionevaluateSync()

Options

OptionTypeDefaultDescription
timeoutnumber0Evaluation timeout in milliseconds. 0 disables timeout checks.
maxDepthnumber100Maximum traversal depth before a MAX_DEPTH security error is thrown.
maxArrayLengthnumber100000Maximum array literal or expanded spread size.
cacheSizenumber256Per-instance cache size for compiled expressions and parsed AST reuse.
allowedPropertiesstring[]-Allowlist checked against every accessed identifier and member name.
deniedPropertiesstring[]-Denylist checked against every accessed identifier and member name.

Blocked names like __proto__, constructor, and prototype are always denied. If you want to allow user.name, you must allow both user and name.

Common setups

ScenarioSuggested approach
App-owned expressions in trusted codeUse bonsai() with defaults and load only the stdlib/plugins you need.
User-authored rules in an admin UISet timeout, maxDepth, maxArrayLength, and an allowedProperties allowlist.
Large catalog of repeated expressionsReuse one instance and consider increasing cacheSize if you have many distinct expression strings.
Tip: Create the instance once at startup and reuse it. Recreating instances on every request throws away the cache and defeats most of the performance work.

evaluateSync & evaluate #

Use the sync path by default. Switch to the async path only when a registered transform or function may return a promise.

Signatures

expr.evaluateSync<T = unknown>(expression, context?) expr.evaluate<T = unknown>(expression, context?)

Both methods treat the context object as the variable scope for the expression. There is no global scope and no hidden runtime state.

APIReturnsUse it when
evaluateSync()TAll registered transforms/functions are synchronous.
evaluate()Promise<T>Any transform/function may await I/O or return a promise.

evaluateSync(expression, context?)

Evaluate immediately and return the result. The context is a plain object whose properties become identifiers in the expression.

expr.evaluateSync('order.total >= freeShippingThreshold', { order: { total: 129 }, freeShippingThreshold: 100 }) // true expr.evaluateSync('subtotal + shippingFee', { subtotal: 89, shippingFee: 12 }) // 101 expr.evaluateSync('customer.country == "GB" && customer.emailVerified', { customer: { country: 'GB', emailVerified: true } }) // true expr.evaluateSync('customer?.email ?? "missing@example.com"', { customer: null }) // "missing@example.com"

evaluate(expression, context?)

Return a promise and await async extensions during evaluation.

expr.addFunction('lookupTier', async (userId) => { const row = await db.users.findById(String(userId)) return row?.tier ?? 'free' }) const isPro = await expr.evaluate( 'lookupTier(userId) == "pro"', { userId: 'u_123' } )

Typed generics

Both entrypoints accept an optional generic for result typing:

const qualifiesForFreeShipping = expr.evaluateSync<boolean>('order.total >= freeShippingThreshold', { order: { total: 129 }, freeShippingThreshold: 100 }) const tier = await expr.evaluate<string>('lookupTier(userId)', { userId: "u_123" })
Tip: If any registered transform, function, or method returns a Promise, evaluateSync() will throw an BonsaiTypeError identifying the offending call and suggesting evaluate() instead. Use evaluate() or compiled .evaluate() for async extensions.

compile & validate #

Use compile() for repeated execution and validate() before you store or accept user-authored expressions.

compile(expression)

Compile once and keep the returned CompiledExpression around for repeated execution.

const rule = expr.compile('order.total >= freeShippingThreshold') rule.evaluateSync({ order: { total: 129 }, freeShippingThreshold: 100 }) // true rule.evaluateSync({ order: { total: 49 }, freeShippingThreshold: 100 }) // false await rule.evaluate({ order: { total: 220 }, freeShippingThreshold: 150 }) // true rule.ast // optimized AST rule.source // original source string

Compiled expressions stay associated with the instance that created them, so they continue to use that instance's safety settings and currently registered transforms/functions.

validate(expression)

Parse without executing. This is useful for editors, validation UIs, and preflight checks before persisting an expression.

expr.validate('order.total >= freeShippingThreshold') // { // valid: true, // errors: [], // ast: {...}, // references: { // identifiers: ["order", "freeShippingThreshold"], // transforms: [], // functions: [] // } // } expr.validate('1 +') // { // valid: false, // errors: [{ // message: "Expected expression", // position: {...}, // formatted: "1 +\n ^ Expected expression" // }] // }

references lists the identifiers, transforms, and functions mentioned in the expression. validate() does not execute the expression and does not verify that those transforms/functions are currently registered.

FieldWhat it tells you
validWhether parsing succeeded
errorsFormatted parse problems when valid is false
astThe parsed syntax tree when validation succeeds
referencesIdentifiers, transforms, and functions mentioned by the expression
Tip: A common production flow is: validate() when the user edits the expression, then compile() once when you accept it.

Extending #

Extend Bonsai with your own transforms, functions, and plugins when you need domain-specific behavior.

If you need...Use...Example
A value flowing through a pipelineaddTransform()price |> usd
A named operation with peer argumentsaddFunction()discount(price, 20)
A reusable package of related behavioruse(plugin)expr.use(currency)

Transforms

Transforms are used with |> and receive the piped value as their first argument.

type TransformFn = (value: unknown, ...args: unknown[]) => unknown | Promise<unknown>
expr.addTransform('cents', (value) => Math.round(Number(value) * 100) ) expr.addTransform('capAt', (value, max) => Math.min(Number(value), Number(max)) ) expr.evaluateSync('price |> cents', { price: 19.99 }) // 1999 expr.evaluateSync('requestedDiscount |> capAt(25)', { requestedDiscount: 40 }) // 25

Functions

Functions are called directly by name in the expression.

type FunctionFn = (...args: unknown[]) => unknown | Promise<unknown>
expr.addFunction('discount', (price, pct) => Number(price) * (1 - Number(pct) / 100) ) expr.addFunction('between', (value, min, max) => Number(value) >= Number(min) && Number(value) <= Number(max) ) expr.evaluateSync('discount(listPrice, 20)', { listPrice: 100 }) // 80 expr.evaluateSync('between(order.total, 50, 200)', { order: { total: 120 } }) // true

Plugins

import type { BonsaiPlugin } from 'bonsai-js' const currency: BonsaiPlugin = (expr) => { expr.addTransform('usd', (value) => `$${Number(value).toFixed(2)}`) expr.addFunction('discount', (price, pct) => Number(price) * (1 - Number(pct) / 100)) } bonsai().use(currency)

Registering the same name again replaces the previous implementation. Transforms and functions live in separate registries, so the same name can exist once in each namespace.

Design rule: keep transforms small and predictable, validate their inputs, and reserve async behavior for cases where you genuinely need I/O. Custom extensions run as normal host JavaScript, so treat them as trusted code.

Instance Methods #

Inspect the registry, remove extensions, and clear caches without rebuilding the entire instance.

MethodReturnsDescription
use(plugin)thisApply a plugin immediately and return the same instance for chaining.
addTransform(name, fn)thisRegister or replace a transform.
addFunction(name, fn)thisRegister or replace a function.
removeTransform(name)booleanUnregister a transform. Returns true if it existed.
removeFunction(name)booleanUnregister a function. Returns true if it existed.
hasTransform(name)booleanCheck if a transform is registered.
hasFunction(name)booleanCheck if a function is registered.
listTransforms()string[]List all registered transform names.
listFunctions()string[]List all registered function names.
clearCache()voidClear the internal AST cache and compiled-expression cache.
const expr = bonsai().use(strings) expr.hasTransform('upper') // true expr.listTransforms() // ["upper", "lower", "trim", ...] expr.removeTransform('upper') // true expr.hasTransform('upper') // false expr.clearCache() // clear AST + compiled caches

evaluateExpression #

A convenience helper for one-off evaluations backed by a shared default instance.

import { evaluateExpression } from 'bonsai-js' evaluateExpression('order.total >= 100', { order: { total: 129 } }) // true evaluateExpression<number>('subtotal + shippingFee', { subtotal: 89, shippingFee: 12 }) // 101

This helper is good for scripts, tests, and one-off evaluations. It uses one shared default instance behind the scenes, so it is intentionally minimal: no custom safety options, no custom plugins, and no lifecycle control.

Use it only for convenience: if your application needs custom plugins, non-default safety settings, cache control, or a clear setup phase, create an instance with bonsai() instead.

Error Handling #

Match on exported error classes and security codes instead of parsing message text.

Runtime error classes

ClassWhenKey Properties
ExpressionErrorParse errors (invalid syntax)source, start, end, suggestion?
BonsaiTypeErrorWrong runtime value type or sync/async mismatchtransform, expected, received, location?, formatted?
BonsaiReferenceErrorUnknown transform, function, or methodkind, identifier, suggestion?, location?, formatted?
BonsaiSecurityErrorSecurity violationscode, location?, formatted?

Evaluation-time errors carry a location when the runtime can attach one, plus a preformatted formatted message for direct display. Parse errors expose source, start, and end directly.

StageRecommended handling
Authoring timeRun validate() and show the formatted parse error inline.
Execution timeCatch exported error classes and branch on the class or security code.
Logging/monitoringRecord the original source string and structured fields, not just the message text.

formatError(message, location, suggestion?) / formatBonsaiError(error)

import { formatError, formatBonsaiError } from 'bonsai-js' formatError('Unexpected token "*"', { source: '1 + * 2', start: 4, end: 5, }) try { expr.evaluateSync('customerName |> upper', { customerName: 42 }) } catch (e) { console.log(formatBonsaiError(e)) }

Security error codes

BonsaiSecurityError uses a code property to identify the type of violation:

CodeDescription
TIMEOUTExpression exceeded the configured timeout
BLOCKED_PROPERTYAccess to __proto__, constructor, or prototype
PROPERTY_NOT_ALLOWEDProperty not in allowedProperties whitelist
PROPERTY_DENIEDProperty is in deniedProperties blacklist
MAX_DEPTHExpression nesting exceeded maxDepth
MAX_ARRAY_LENGTHArray size exceeded maxArrayLength

Catching errors

import { ExpressionError, BonsaiTypeError, BonsaiReferenceError, BonsaiSecurityError, formatError, formatBonsaiError } from 'bonsai-js' try { expr.evaluateSync('customerName |> unknownTransform', { customerName: "Alicia" }) } catch (e) { if (e instanceof BonsaiReferenceError) { console.log(e.kind) // "transform" console.log(e.identifier) // "unknownTransform" console.log(e.suggestion) // "upper" (if similar name exists) console.log(e.location) // { start: 15, end: 31 } console.log(e.formatted) // full source-highlighted message } else if (e instanceof BonsaiSecurityError) { console.log(e.code) // "TIMEOUT", "BLOCKED_PROPERTY", etc. console.log(formatBonsaiError(e)) } else if (e instanceof ExpressionError) { console.log(e.message) // parse error description console.log(e.source) // the original expression string } }
Tip: Match on the error class or the security code. Message text can change more easily than the exported error types.

Strings #

Load the string stdlib when you need text cleanup, normalization, search, and formatting inside pipelines.

import { strings } from 'bonsai-js/stdlib' expr.use(strings)
TransformExampleResult
upper"enterprise" |> upper"ENTERPRISE"
lower"Enterprise" |> lower"enterprise"
trim" Alicia " |> trim"Alicia"
split(sep)"admin,billing,ops" |> split(",")["admin", "billing", "ops"]
replace(s, r)"VAT 20%" |> replace("20", "5")"VAT 5%"
replaceAll(s, r)"Acme Billing Docs" |> replaceAll(" ", "-")"Acme-Billing-Docs"
startsWith(s)"invoice-1042" |> startsWith("invoice-")true
endsWith(s)"alice@acme.com" |> endsWith("@acme.com")true
includes(s)"priority-support" |> includes("support")true
padStart(len, fill)"42" |> padStart(6, "0")"000042"
padEnd(len, fill)"SKU" |> padEnd(6, "_")"SKU___"

Common patterns:

customerName |> trim |> lower "alicia smith" { customerName: " Alicia Smith " } clean and normalize input
articleTitle |> lower |> replaceAll(" ", "-") "shipping-policy-update" { articleTitle: "Shipping Policy Update" } build a URL slug
Good fit for string transforms: normalization, slug building, padding, and search checks. If you only need a couple of built-in methods like slice() or includes(), you can also use the core method-call syntax shown earlier.

Arrays #

Load the array stdlib when your expressions need collection work: counting, searching, projection, deduping, and simple aggregation.

import { arrays } from 'bonsai-js/stdlib' expr.use(arrays)

Basic transforms

TransformExampleResult
countselectedRegions |> count3
firstplanTiers |> first"starter"
lastreleaseStages |> last"production"
reverseapprovalSteps |> reverse["legal", "finance", "ops"]
flattentagGroups |> flatten["billing", "finance", "ops"]
uniqueregions |> unique["gb", "us", "de"]
join(sep)invoiceIds |> join(", ")"INV-1, INV-2"
sortpriorityScores |> sort[1, 2, 3]

sort sorts numbers numerically and falls back to string comparison for other values.

Higher-order transforms

These accept lambda predicates as arguments for filtering, mapping, and searching.

TransformExampleResult
filter(pred)users |> filter(.age >= 18)Array of matching items
map(fn)users |> map(.name)Array of extracted values
find(pred)users |> find(.age >= 18)First matching item
some(pred)users |> some(.age >= 18)true if any match
every(pred)users |> every(.age >= 18)true if all match

Practical examples:

users |> filter(.age >= 18) |> map(.name) ["Alice", "Charlie"] get names of adult users
orders |> some(.total > 100) true check if any order exceeds $100
products |> map(.category) |> unique |> sort ["electronics", "food", "toys"] deduplicate and sort categories
Most array errors are type mismatches: these transforms expect arrays. If the input might be nullable or mixed, normalize it before you reach the expression or guard it with your own custom transform.

Math #

Load the math stdlib for rounding, clamping, and summarizing numeric values in pipelines.

import { math } from 'bonsai-js/stdlib' expr.use(math)

Transforms

TransformExampleResult
roundaverageRating |> round4
floormonthlySeats |> floor3
ceilstorageUnits |> ceil4
absbalanceDelta |> abs42
sumorderTotals |> sum60
avgorderTotals |> avg20
clamp(min, max)requestedDiscount |> clamp(0, 25)25

Functions

FunctionExampleResult
min(a, b)min(order.total, budgetCap)smaller value
max(a, b)max(order.total, minimumBillable)larger value

Common patterns:

products |> map(.price) |> avg |> round 25 average price, rounded
orders |> map(.total) |> sum 347.5 sum all order totals
Keep the types clean: math transforms expect numbers, and sum/avg expect arrays of numbers. If your input is textual, convert it first with toNumber or a custom transform.

Types #

Load the types stdlib when the input shape is mixed and you need defensive checks or simple conversions.

import { types } from 'bonsai-js/stdlib' expr.use(types)
TransformExampleResult
isStringcustomer.email |> isStringtrue
isNumberorder.total |> isNumbertrue
isArraycart.items |> isArraytrue
isNullcustomer.nickname |> isNulltrue
toBoolfeatureFlag |> toBooltrue
toNumber"42.50" |> toNumber42.5
toStringinvoiceNumber |> toString"1042"

Type checks are useful when the same field may arrive in different shapes:

// Accept both numeric totals and totals sent as strings (order.total |> isNumber) ? order.total : (order.total |> toNumber |> round)
Use sparingly: if every expression in your system needs the same coercion step, it is usually cleaner to normalize the data before evaluation and keep the expressions simpler.

Dates #

Load the dates stdlib when you need current timestamps, UTC formatting, or day differences between timestamps.

import { dates } from 'bonsai-js/stdlib' expr.use(dates)
TypeNameExampleResult
Functionnow()now()Current Unix timestamp in milliseconds
TransformformatDate(fmt)1704067200000 |> formatDate("YYYY-MM-DD")"2024-01-01"
TransformdiffDays(other)ts1 |> diffDays(ts2)Absolute day difference

All date transforms work with Unix timestamps in milliseconds. formatDate() uses UTC components, not local time-zone formatting.

Format patterns: YYYY (year), MM (month), DD (day), HH (hours), mm (minutes), ss (seconds).

// Fixed timestamp example 1704153600000 |> formatDate("YYYY-MM-DD HH:mm:ss") // "2024-01-02 00:00:00" // Difference in whole days 1704153600000 |> diffDays(1704067200000) // 1

All (bundle) #

Load every standard library module at once - for quick prototyping or when you want everything available.

import { all } from 'bonsai-js/stdlib' const expr = bonsai().use(all) // All transforms and functions from strings, arrays, math, types, and dates are now available
Tip: Start with all if you are exploring or prototyping. For production packages, prefer individual modules (strings, arrays, and so on) so your public setup is more deliberate and your bundle stays smaller.

Writing Plugins #

Plugins are the packaging layer for your expression API. Use them to ship a cohesive set of transforms and functions for one domain.

Plugin structure

A plugin is a function that receives a Bonsai instance and registers whatever that domain needs.

import type { BonsaiPlugin } from 'bonsai-js' const billingPlugin: BonsaiPlugin = (expr) => { expr.addTransform('usd', (value) => `$${Number(value).toFixed(2)}`) expr.addFunction('discount', (price, pct) => Number(price) * (1 - Number(pct) / 100)) }

Registering plugins

Apply plugins during setup, not in the middle of request handling. That keeps the instance predictable and the caches warm.

const expr = bonsai() expr.use(billingPlugin) expr.evaluateSync('price |> usd', { price: 29.9 }) // "$29.90" expr.evaluateSync('discount(price, 20)', { price: 100 }) // 80
Good plugin habitsWhy they matter
Keep a plugin focused on one domainEasier to explain, test, and compose
Validate inputs inside transforms/functionsExpression authors get clearer runtime errors
Prefer stable names over clever namesExpressions become part of your product surface
Register plugins once at startupAvoids behavioral drift across requests

Real-world example: currency plugin

Here is a practical plugin for formatting and calculating prices:

const currency: BonsaiPlugin = (expr) => { // Transforms for formatting expr.addTransform('usd', (val) => `$${Number(val).toFixed(2)}`) expr.addTransform('eur', (val) => `${Number(val).toFixed(2)} \u20AC`) // Function for discounts expr.addFunction('discount', (price, pct) => price * (1 - pct / 100)) } const expr = bonsai() expr.use(currency) expr.evaluateSync('price |> usd', { price: 29.9 }) // "$29.90" expr.evaluateSync('discount(price, 20) |> usd', { price: 100 }) // "$80.00"
Tip: Treat plugin names and expression syntax as product API. If another team will read or author these expressions, bias toward boring, obvious names over DSL cleverness.

Safety & Sandboxing #

Bonsai constrains the expression language. It is suitable for safe expression evaluation, but your own registered extensions still run as normal host JavaScript.

What Bonsai blocks by default

Expressions cannot access the global scope, import modules, or reach dangerous prototype properties.

"hello".__proto__ // Error: Access to __proto__ is not allowed constructor // Error: Access to constructor is not allowed

Property restrictions

allowedProperties and deniedProperties apply to member access (obj.name) and method calls (str.slice()), not root identifiers (name) or object-literal keys ({ name: value }). Numeric array indices (e.g., items[0]) bypass allow/deny lists automatically.

const expr = bonsai({ allowedProperties: ['user', 'name', 'plan'] }) expr.evaluateSync('user.name', { user: { name: "Alice", plan: "pro", secret: "xyz" } }) // "Alice" expr.evaluateSync('user.secret', { user: { secret: "xyz" } }) // Error: "secret" is not in allowed properties

If you want to permit user.name, you must allow both user and name as member names. Root identifiers like user in evaluateSync('user.name', { user }) are always accessible -the allow/deny lists only restrict what comes after the dot. Use an allowlist for user-authored expressions whenever possible.

Defense-in-depth hardening

Beyond the configurable property restrictions, Bonsai applies several layers of protection automatically:

ProtectionWhat it does
Own-property-only lookupRoot identifiers are resolved via Object.hasOwn(), so context prototype chains cannot leak inherited properties into expressions.
Null-prototype object literalsObjects created inside expressions (e.g., { a: 1 }) use Object.create(null), preventing prototype pollution through expression-constructed objects.
Receiver-aware method validationMethod calls validate that the receiver is a safe type (string, number, array, or plain object). Calling methods on unexpected receiver types throws a BonsaiTypeError.
Numeric index bypassCanonical numeric array indices (e.g., items[0]) automatically bypass allow/deny lists, so you don't need to whitelist numeric strings.
Sync Promise guardevaluateSync() detects Promise return values from transforms, functions, and methods, and throws an actionable BonsaiTypeError naming the offending call and suggesting evaluate().

Recommended deployment profiles

ScenarioRecommended posture
Trusted internal expressionsDefaults are often fine, but still keep custom plugins small and explicit.
User-authored business rulesUse an allowlist, set all resource limits, validate before save, and compile only accepted expressions.
Higher-risk multi-tenant environmentsUse the Bonsai limits plus worker/process isolation for stronger containment.

Resource limits

Protect against resource exhaustion with timeout, maxDepth, and maxArrayLength.

const expr = bonsai({ timeout: 50, // cooperative timeout in ms maxDepth: 50, // max nesting depth maxArrayLength: 10000 // max array size })

What Bonsai does not do

ConcernWhat to know
Custom transforms/functionsThey run as normal host JavaScript. Bonsai does not sandbox code you register yourself.
TimeoutsTimeout checks are cooperative during evaluator traversal. They do not forcibly interrupt arbitrary synchronous host code.
Async callbacksAsync time is checked at awaited boundaries, not by cancellation of the underlying I/O.
Hard isolationIf you need a stronger boundary, run evaluation in a worker or separate process.
Tip: For user-authored expressions, start with a minimal context object, use allowedProperties, set all three limits, and treat every custom plugin as trusted application code.

Performance #

The fastest path is a reused instance, compiled hot-path expressions, and sync execution when your extensions do not need promises.

NeedBest choice
One quick sync evaluationevaluateSync()
Repeated hot-path evaluationcompile() once, then compiled.evaluateSync()
Async plugin or function callevaluate() or compiled.evaluate()
Small bundle surfaceImport only the stdlib modules you actually use

Recommended usage

PracticeWhy it helps
Create one instance and reuse itKeeps the per-instance caches warm and avoids repeated setup work.
Compile repeated expressionsAvoids repeated parse/compile work and gives you an explicit reusable handle.
Prefer evaluateSync()Avoids promise overhead when your transforms/functions are synchronous.
Import only the stdlib modules you needKeeps bundle size and startup overhead down.
Use clearCache() sparinglyClearing caches is useful for churny workloads, but it throws away warm-state performance.

Benchmarks #

Measured with vitest bench on Apple Silicon (M-series). Treat these as point-in-time guidance, not part of the API contract.

ExpressionExampleops/secµs/op
Simple literal 42 ~30,000,000 0.033
Arithmetic 1 + 2 * 3 ~30,000,000 0.033
Property access user.name ~21,000,000 0.048
Comparison + logic user.age >= 18 && user.verified ~11,000,000 0.091
Transform pipeline user.name |> upper ~12,000,000 0.083
Array operations items |> sum ~17,000,000 0.059

vs Jexl

ScenarioSpeedup
Default usage (parse + evaluate)88x faster
Pre-compiled literal3.2x faster
Pre-compiled comparison5.6x faster

Run benchmarks yourself: bunx vitest bench

Why it's fast

  • Per-instance caches - repeated expressions reuse parsed ASTs and compiled expression objects.
  • Compiler optimizations - Constant folding and dead branch elimination at compile time.
  • Zero dependencies - no runtime dependency graph to initialize or ship.
  • Hand-written parser - Recursive descent, no parser generators or regex-heavy tokenizers.

Optimization tips

Use compile() for repeated expressions. If the same expression runs in a loop or on every request, compile it once and reuse the compiled object. This skips parsing and optimization entirely.

// Good: compile once, evaluate many const rule = expr.compile('order.total >= freeShippingThreshold && order.country == "GB"') for (const order of orders) { if (rule.evaluateSync({ order, freeShippingThreshold: 100 })) { /* ... */ } }

Use evaluateSync over evaluate. The sync path avoids Promise overhead. Only use evaluate when you have async transforms.

Choose an appropriate cache size. The default (256) works well for most applications. If you have thousands of unique expression strings, increase it. If memory is tight, decrease it.

// High-volume: larger cache const expr = bonsai({ cacheSize: 1024 }) // Memory-constrained: smaller cache const expr = bonsai({ cacheSize: 64 })
Most apps do not need benchmark chasing: the biggest wins usually come from reusing one instance, compiling repeated expressions, and avoiding async evaluation unless you genuinely need it.