
A safe expression language for rules, filters, templates, and user-authored logic. Runs in any JavaScript runtime.
Bonsai gives you a constrained expression language with caching, typed errors, pluggable transforms/functions, and safety controls. It is designed for cases where eval() would be inappropriate: business rules, formula fields, admin-defined filters, template helpers, and product configuration.
bun add bonsai-js
# or
npm install bonsai-js
npm · Playground · Docs
import { bonsai } from 'bonsai-js'
import { arrays, math, strings } from 'bonsai-js/stdlib'
const expr = bonsai()
.use(strings)
.use(arrays)
.use(math)
expr.evaluateSync('1 + 2 * 3') // 7
expr.evaluateSync('user.age >= 18', {
user: { age: 25 },
}) // true
expr.evaluateSync('name |> trim |> upper', {
name: ' dan ',
}) // 'DAN'
expr.evaluateSync('users |> filter(.age >= 18) |> map(.name)', {
users: [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 15 },
],
}) // ['Alice']
// JS-style method chaining works too
expr.evaluateSync('users.filter(.age >= 18).map(.name)', {
users: [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 15 },
],
}) // ['Alice']
expr.evaluateSync('[1, 2, 3, 4].filter(. > 2)') // [3, 4]
expr.evaluateSync('user?.profile?.avatar ?? "default.png"', {
user: null,
}) // 'default.png'
| Need | API |
|---|---|
| Repeated evaluations with caching, plugins, or safety options | bonsai() |
| One-off evaluation with default behavior | evaluateExpression() |
| Hot-path reuse of the same expression | compile() |
| Syntax checks and reference extraction before execution | validate() |
| Async transforms or async functions | evaluate() / compiled .evaluate() |
| Sync-only execution | evaluateSync() / compiled .evaluateSync() |
import { bonsai } from 'bonsai-js'
const expr = bonsai({
timeout: 50,
maxDepth: 50,
allowedProperties: ['user', 'age', 'country', 'plan'],
})
const isEligible = expr.compile('user.age >= 18 && user.country == "GB" && user.plan == "pro"')
isEligible.evaluateSync({
user: { age: 25, country: 'GB', plan: 'pro' },
}) // true
import { bonsai } from 'bonsai-js'
const expr = bonsai()
expr.addFunction('lookupTier', async (userId) => {
const row = await db.users.findById(String(userId))
return row?.tier ?? 'free'
})
await expr.evaluate('lookupTier(userId) == "pro"', { userId: 'u_123' })
Register functions that read the evaluation context directly. The function receives the evaluation context as its first parameter (typed read-only), so you can keep expressions terse and let the function pull what it needs:
import { bonsai } from 'bonsai-js'
interface AppContext {
currentUserId: string
perms: readonly string[]
}
const app = bonsai<AppContext>()
app.addContextFunction('lookupCurrentUserTier', async (ctx) => {
const row = await db.users.findById(ctx.currentUserId)
return row?.tier ?? 'free'
})
app.addContextFunction('hasPermission', (ctx, action) =>
ctx.perms.includes(String(action)))
await app.evaluate(
'lookupCurrentUserTier() == "pro" && hasPermission("admin")',
{ currentUserId: 'u_123', perms: ['admin', 'write'] },
)
The instance is generic over the context type (bonsai<AppContext>()), giving
you end-to-end type safety: ctx is typed inside the function, and the call
site is type-checked against the same shape. If your context type has required
fields, TypeScript also requires you to pass the context argument to
evaluate, evaluateSync, and compiled-expression evaluation.
The context is passed to your function by reference, not copied or frozen. The
Readonly<TCtx> parameter type signals that you should treat it as read-only:
TypeScript flags reassigning its top-level fields. Bonsai does not deep-freeze
it, so nested mutation and writes from untyped JavaScript reach the object you
passed in. If you need isolation between evaluations, pass a fresh context
object each time.
Pure functions (addFunction) and context-aware functions (addContextFunction)
share a single namespace. Registering the same name with either method
overwrites the prior registration, so re-registering a context-aware name with
addFunction turns it back into a pure function (check isContextFunction(name)
if the kind matters). Functions registered with addFunction never receive the
context; reach for addContextFunction when a function needs it. Plugins typed
against a minimal context requirement can still be applied to instances with a
wider context shape.
const result = expr.validate('user.name |> upper')
if (result.valid) {
result.references.identifiers // ['user']
result.references.transforms // ['upper']
} else {
console.error(result.errors[0]?.formatted)
}
Bonsai supports two styles for working with arrays: pipe transforms and JS-style method chaining. Both use the same lambda shorthand.
Inside array methods, . refers to the current item:
.property — access a property on each item (e.g., .age, .name). > value — compare each item directly (e.g., . > 2, . == "x")Compound predicates work too: .age >= 18 && .active
A lambda is built from the accessor plus operators, member access, and methods. The shorthand is itself a function value, so it cannot be passed into another function call: map(myFn(.x)) does not mean map(item => myFn(item.x)) and will throw a BonsaiTypeError. To transform each item through a function, chain instead: items |> map(.x) |> map(myFn) (or items.map(.x).map(myFn)).
import { arrays } from 'bonsai-js/stdlib'
const expr = bonsai().use(arrays)
expr.evaluateSync('users |> filter(.age >= 18) |> map(.name)', {
users: [{ name: 'Alice', age: 25 }, { name: 'Bob', age: 15 }],
}) // ['Alice']
expr.evaluateSync('[1, 2, 3, 4] |> filter(. > 2)') // [3, 4]
No stdlib import required — filter, map, find, some, and every work as native array methods:
const expr = bonsai()
expr.evaluateSync('users.filter(.age >= 18).map(.name)', {
users: [{ name: 'Alice', age: 25 }, { name: 'Bob', age: 15 }],
}) // ['Alice']
expr.evaluateSync('[1, 2, 3, 4].filter(. > 2)') // [3, 4]
expr.evaluateSync('[1, 2, 3].map(. * 10)') // [10, 20, 30]
expr.evaluateSync('[1, 2, 3].find(. > 1)') // 2
expr.evaluateSync('[1, 2, 3].some(. > 2)') // true
expr.evaluateSync('[1, 2, 3].every(. > 0)') // true
Both styles support async evaluation via evaluate().
These methods work via native .method() syntax without any imports. Mutating methods (reverse, sort, push, pop, splice, etc.) are blocked to prevent context mutation.
String methods:
| Method | Example |
|---|---|
trim, trimStart, trimEnd |
" hi ".trim() → "hi" |
toLowerCase, toUpperCase |
"Hello".toLowerCase() → "hello" |
startsWith, endsWith |
"hello".startsWith("hel") → true |
includes, indexOf, lastIndexOf |
"hello".includes("ell") → true |
slice, substring, at |
"hello".slice(1, 3) → "el" |
replace, replaceAll |
"abc".replace("a", "x") → "xbc" |
split |
"a,b,c".split(",") → ["a", "b", "c"] |
padStart, padEnd |
"5".padStart(3, "0") → "005" |
charAt, charCodeAt, repeat, concat |
"ab".repeat(2) → "abab" |
Array methods (with lambda support):
| Method | Example |
|---|---|
filter |
[1,2,3].filter(. > 1) → [2, 3] |
map |
[1,2,3].map(. * 10) → [10, 20, 30] |
find, findIndex |
[1,2,3].find(. > 1) → 2 |
some, every |
[1,2,3].some(. > 2) → true |
flatMap |
[[1],[2,3]].flatMap(.) |
Array methods (non-callback):
| Method | Example |
|---|---|
join |
[1,2,3].join(", ") → "1, 2, 3" |
includes, indexOf, lastIndexOf |
[1,2,3].includes(2) → true |
slice, at, concat, flat |
[1,2,3].concat([4]) → [1, 2, 3, 4] |
toReversed, toSorted, toSpliced, with |
[3,1,2].toSorted() → [1, 2, 3] |
Number methods: toFixed, toString
Pipe-only transforms (require stdlib import): count, first, last, reverse, flatten, unique, sort, upper, lower, trim, sum, avg, clamp, and more. See Standard Library for the full list.
bonsai(options?)Creates a reusable evaluator instance with its own extension registry and caches.
import { bonsai } from 'bonsai-js'
const expr = bonsai(options?: BonsaiOptions)
BonsaiOptions:
| Option | Type | Default | Notes |
|---|---|---|---|
timeout |
number |
0 |
Evaluation timeout in milliseconds. 0 disables the timeout check. |
maxDepth |
number |
100 |
Maximum evaluation depth before throwing BonsaiSecurityError('MAX_DEPTH', ...). |
maxArrayLength |
number |
100000 |
Maximum array size produced during evaluation, including array literals, expanded spread, and array-returning methods (split, map, flat, concat, ...). Exceeding it throws BonsaiSecurityError('MAX_ARRAY_LENGTH', ...). |
maxStringLength |
number |
100000 |
Maximum string size produced by a string-returning method (padStart, padEnd, repeat, join, concat, slice, ...). Applies to the produced length (e.g. arr.join(sep) is bounded by its full output, not just the inputs). Exceeding it throws BonsaiSecurityError('MAX_STRING_LENGTH', ...). |
cacheSize |
number |
256 |
Per-instance cache size for compiled expressions and parsed AST reuse. 0 disables caching. |
allowedProperties |
string[] |
undefined |
Whitelist of allowed member/method names. Does not apply to root identifiers or object-literal keys. |
deniedProperties |
string[] |
undefined |
Denylist of blocked member/method names. Does not apply to root identifiers or object-literal keys. |
Important notes:
cacheSize, a non-positive maxDepth, a negative size limit, a negative/non-finite timeout) throw a RangeError/TypeError immediately rather than failing silently later.allowedProperties and deniedProperties apply to member access (obj.name) and method calls (str.slice()), not root identifiers (name) or object-literal keys ({ name: value }).user.name, you must allow both user and name as member names.items[0]) bypass allow/deny lists automatically.__proto__, constructor, and prototype are always blocked at every access level, even if you include them in an allowlist.evaluateSync<T>(expression, context?)Runs an expression synchronously and returns its result immediately.
const result = expr.evaluateSync<number>('price * quantity', {
price: 9.99,
quantity: 3,
})
Use this when:
If any registered transform, function, or method returns a Promise, evaluateSync() will throw an BonsaiTypeError identifying the offending call and suggesting evaluate() instead.
evaluate<T>(expression, context?)Runs an expression asynchronously and returns a Promise<T>.
const tier = await expr.evaluate<string>('userId |> fetchTier', {
userId: 'u_123',
})
Use this when:
compile(expression)Compiles an expression once and returns a reusable CompiledExpression.
const compiled = expr.compile('user.age >= minAge')
compiled.evaluateSync({ user: { age: 25 }, minAge: 18 }) // true
compiled.evaluateSync({ user: { age: 15 }, minAge: 18 }) // false
await compiled.evaluate({ user: { age: 21 }, minAge: 21 }) // true
Use compile() when the same expression
$ claude mcp add bonsai-js \
-- python -m otcore.mcp_server <graph>