bonsai - a safe expression language for JS that runs user-defined rules at 30M ops/sec with zero dependencies and no eval()

This problem has come up enough times in my work that I got tired of solving it badly. At some point on certain products a stakeholder asks "can admins set up their own conditions for this?" and you realize a dropdown isn't going to cut it. They need real logic: order.total > 100 && customer.tier == "gold".

The options all felt bad:

  • Hardcoded switch statements. Every new rule is a deploy. The "configurable" feature isn't configurable.
  • A homegrown mini-DSL. Starts as three operators, ends as a parser nobody wants to own.
  • eval() / new Function() / vm**.** The moment user input touches these, you've handed out a shell. vm isn't a security boundary (the docs literally say so), and vm2 is deprecated. Prototype pollution alone (constructor.constructor) is enough to ruin your week.

I got tired of rebuilding the bad version, so I built the thing I actually wanted: bonsai, a safe expression language for the cases where eval() would be inappropriate but a dropdown is too weak.

If you'd rather poke at it than read, there's a browser playground (no install): https://danfry1.github.io/bonsai-js/playground.html

import { bonsai } from 'bonsai-js'

const expr = bonsai()

// An admin-authored rule, stored as a plain string in your DB
expr.evaluateSync('user.age >= 18 && user.plan == "pro"', {
  user: { age: 25, plan: 'pro' },
}) // true

It's an expression language, not a scripting language. No statements, no loops, no assignment, no I/O. You get the expressive part (the part users actually need) without the part that gets you owned.

What the syntax supports, so it doesn't feel like a toy:

// optional chaining + nullish coalescing
expr.evaluateSync('user?.profile?.avatar ?? "default.png"', { user: null })

// pipe operator with transforms
expr.evaluateSync('name |> trim |> upper', { name: '  dan  ' }) // 'DAN'

// lambda shorthand in array methods
expr.evaluateSync('users.filter(.age >= 18).map(.name)', {
  users: [{ name: 'Alice', age: 25 }, { name: 'Bob', age: 15 }],
}) // ['Alice']

The security model is the whole point, so here's what's actually enforced:

  • __proto__, constructor, prototype blocked at every access level (no prototype-chain walking)

  • Object literals created with null prototypes

  • No globals, no code generation

  • Cooperative timeouts, max depth, max array/string length

  • Per-instance property allowlists/denylists, so you decide exactly what an expression can touch

    const expr = bonsai({ timeout: 50, maxDepth: 50, allowedProperties: ['user', 'age', 'country', 'plan'], })

A few things I cared about that might matter to you:

  • Zero dependencies. Nothing in your tree but this.
  • Any JS runtime. Node, Bun, browser, edge.
  • Fast when it needs to be. There's a compile() API for rules that run thousands of times; cached expressions hit ~30M ops/sec.
  • Async escape hatch. You can register your own functions (async (id) => db.lookup(id)) and await expr.evaluate(...), so a rule can call back into your system without the language itself having any I/O.

Once it existed, it ended up covering a bunch of "logic that lives outside the code" cases for me: admin-defined rules, server-driven conditions stored as config, formula fields, feature-flag targeting. Anywhere a string needs to become a decision without a deploy.

Playground · Docs · GitHub · npm

Mostly I'm curious how other people have handled this. If you've shipped user-defined rules/filters/formulas in production, what did you reach for, and where did it bite you? Happy to hear it if you think this is the wrong approach too.

github.com
u/danfry99 — 3 months ago