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 })
Bonsai is a constrained expression language for rules, filters, and template logic. Use it when product logic should be configurable and expressive, but still safe to run in production.
Install Bonsai with your preferred package manager. The package is ESM-only with TypeScript types included.
Then import and start evaluating:
evaluateSync() for sync paths, and use compile() for repeated rules.
Start from the job you need to do. Most users do not need to read the docs from top to bottom.
Use this for request-time checks, UI previews, conditional rendering, and simple rules.
expr.evaluateSync('order.total >= freeShippingThreshold', { order, freeShippingThreshold })
Reuse a rule
Use this when the same expression runs over many different records or requests.
const rule = expr.compile('order.country == "GB" && order.total >= 100')
Validate before save
Use this for rule builders, editors, forms, and admin tools.
const result = expr.validate('order.total >= minTotal')
Add app logic
Use this when the built-in syntax is not enough and you want app-specific capabilities.
expr.addFunction('discount', fn)
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.
Reuse this instance instead of recreating it. That keeps the cache warm and gives you one place to register custom functions or safety settings.
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 } }
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" }] }
evaluate() only when you need async host code. If your transforms and functions are synchronous, evaluateSync() is the simpler and faster default.
You only need four ideas to work effectively with Bonsai.
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 })
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 }
Transforms read naturally from left to right, which is why most formatting and collection work is easiest to express with |>.
customerName |> trim |> upper
If an expression will run many times, compile it once and keep the returned object around.
const rule = expr.compile('order.total >= freeShippingThreshold')
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)truetruebooleannullnullundefinedundefineduseful with ?? fallbacks3.143.14decimals1_000_0001000000underscores for readability (ignored)0xFF255hexadecimal0b101010binary1.5e31500scientific notationcontext object and reference it by name.
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... | Use | Example |
|---|---|---|
| Do math | + - * / % ** | subtotal + shippingFee |
| Compare values | == != < > <= >= | order.total >= freeShippingThreshold |
| Branch or provide defaults | ?:, ??, ||, && | customer.nickname ?? customer.firstName |
| Test membership | in, not in | "pro" in plans |
basePrice + addOnPrice * seats85{ basePrice: 49, addOnPrice: 12, seats: 3 }* runs before +2 ** 101024exponentiationorder.total >= minimumOrdertrue{ order: { total: 149 }, minimumOrder: 100 }1 == "1"falsestrict - no type coercion, everUse ?? 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 rulecustomer.nickname ?? customer.firstName"Alicia"?? falls back only for null or undefinedcouponCode || "no-code""no-code"|| also falls back for empty strings and other falsy valuesin 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(subtotal + shippingFee) * taxMultiplier129.6parentheses make billing logic explicitremainingSeats % seatsPerRow2remainder is useful for layout and batching rulesorder.paid && order.fulfilledtrue!user.suspendedtrueboolean negationnickname ?? "Guest"""?? preserves empty strings and other defined falsy values"legacy" not in enabledFeaturestrueUse 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.
| Pattern | Use it for |
|---|---|
user.name | Read 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 |
customer.name"Alicia"order.items[0].sku"SKU-001"profile["first-name"]"Alicia"brackets for keys with special charactersUse ?. 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 throwncustomer?.billing?.country ?? "GB""GB"combine with ?? for fallbacksmessages?.[locale] ?? messages?.en"Bonjour"optional chaining also works with computed keysuser.secret should be blocked, secret must stay out of allowedProperties.
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.
|> passes the left-hand value as the first argument to the transform on the right.
status |> upper
"PENDING"
{ status: "pending" }
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 " }
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 }
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" }] }
min(a, b) or discount(price, pct), a function call is usually clearer.
Collections let you build structured results directly inside an expression. Use them when the result should be data, not just a boolean or string.
| Pattern | Use 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 |
["starter", "analytics", "priority-support"]["starter", "analytics", "priority-support"][plan, ...addons, "priority-support"]["starter", "analytics", "exports", "priority-support"]spread accepts iterable values{ 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 supportedCore 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_")trueselectedRegions.includes("gb")trueinvoiceFile.slice(0, 7)"invoice"orderTotal.toFixed(2)"129.90"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 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"Lambdas are Bonsai's shorthand for “do this to each item” inside array transforms like map, filter, find, some, and every.
| Shorthand | Meaning | Typical use |
|---|---|---|
.id | Read the current item's id | orders |> map(.id) |
.price < 100 | Test the current item | products |> filter(.price < 100) |
.email.endsWith("@acme.com") | Use member access and safe method calls on the current item | users |> some(.email.endsWith("@acme.com")) |
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"]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 }]orders |> find(.status == "failed"){ id: "INV-1002", status: "failed" }first matchorders |> some(.overdue)trueat least one matchdeployments |> every(.successful)falsenot all matchLambdas 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" }]
Create one configured evaluator instance and reuse it anywhere you need expression execution, validation, or compiled rules.
| If you need... | Use... |
|---|---|
| Repeated evaluations with custom options, plugins, or cache reuse | bonsai() |
| A quick one-off evaluation with default behavior | evaluateExpression() |
| A reusable hot-path rule object | compile() |
| Syntax checks and reference extraction before execution | validate() |
| Async transforms or async functions | evaluate() |
| Lowest-overhead sync execution | evaluateSync() |
| Option | Type | Default | Description |
|---|---|---|---|
timeout | number | 0 | Evaluation timeout in milliseconds. 0 disables timeout checks. |
maxDepth | number | 100 | Maximum traversal depth before a MAX_DEPTH security error is thrown. |
maxArrayLength | number | 100000 | Maximum array literal or expanded spread size. |
cacheSize | number | 256 | Per-instance cache size for compiled expressions and parsed AST reuse. |
allowedProperties | string[] | - | Allowlist checked against every accessed identifier and member name. |
deniedProperties | string[] | - | 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.
| Scenario | Suggested approach |
|---|---|
| App-owned expressions in trusted code | Use bonsai() with defaults and load only the stdlib/plugins you need. |
| User-authored rules in an admin UI | Set timeout, maxDepth, maxArrayLength, and an allowedProperties allowlist. |
| Large catalog of repeated expressions | Reuse one instance and consider increasing cacheSize if you have many distinct expression strings. |
Use the sync path by default. Switch to the async path only when a registered transform or function may return a promise.
Both methods treat the context object as the variable scope for the expression. There is no global scope and no hidden runtime state.
| API | Returns | Use it when |
|---|---|---|
evaluateSync() | T | All registered transforms/functions are synchronous. |
evaluate() | Promise<T> | Any transform/function may await I/O or return a promise. |
Evaluate immediately and return the result. The context is a plain object whose properties become identifiers in the expression.
Return a promise and await async extensions during evaluation.
Both entrypoints accept an optional generic for result typing:
Promise, evaluateSync() will throw an BonsaiTypeError identifying the offending call and suggesting evaluate() instead. Use evaluate() or compiled .evaluate() for async extensions.
Use compile() for repeated execution and validate() before you store or accept user-authored expressions.
Compile once and keep the returned CompiledExpression around for repeated execution.
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.
Parse without executing. This is useful for editors, validation UIs, and preflight checks before persisting an 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.
| Field | What it tells you |
|---|---|
valid | Whether parsing succeeded |
errors | Formatted parse problems when valid is false |
ast | The parsed syntax tree when validation succeeds |
references | Identifiers, transforms, and functions mentioned by the expression |
validate() when the user edits the expression, then compile() once when you accept it.
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 pipeline | addTransform() | price |> usd |
| A named operation with peer arguments | addFunction() | discount(price, 20) |
| A reusable package of related behavior | use(plugin) | expr.use(currency) |
Transforms are used with |> and receive the piped value as their first argument.
Functions are called directly by name in the expression.
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.
Inspect the registry, remove extensions, and clear caches without rebuilding the entire instance.
| Method | Returns | Description |
|---|---|---|
use(plugin) | this | Apply a plugin immediately and return the same instance for chaining. |
addTransform(name, fn) | this | Register or replace a transform. |
addFunction(name, fn) | this | Register or replace a function. |
removeTransform(name) | boolean | Unregister a transform. Returns true if it existed. |
removeFunction(name) | boolean | Unregister a function. Returns true if it existed. |
hasTransform(name) | boolean | Check if a transform is registered. |
hasFunction(name) | boolean | Check if a function is registered. |
listTransforms() | string[] | List all registered transform names. |
listFunctions() | string[] | List all registered function names. |
clearCache() | void | Clear the internal AST cache and compiled-expression cache. |
A convenience helper for one-off evaluations backed by a shared default instance.
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.
bonsai() instead.
Match on exported error classes and security codes instead of parsing message text.
| Class | When | Key Properties |
|---|---|---|
ExpressionError | Parse errors (invalid syntax) | source, start, end, suggestion? |
BonsaiTypeError | Wrong runtime value type or sync/async mismatch | transform, expected, received, location?, formatted? |
BonsaiReferenceError | Unknown transform, function, or method | kind, identifier, suggestion?, location?, formatted? |
BonsaiSecurityError | Security violations | code, 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.
| Stage | Recommended handling |
|---|---|
| Authoring time | Run validate() and show the formatted parse error inline. |
| Execution time | Catch exported error classes and branch on the class or security code. |
| Logging/monitoring | Record the original source string and structured fields, not just the message text. |
BonsaiSecurityError uses a code property to identify the type of violation:
| Code | Description |
|---|---|
TIMEOUT | Expression exceeded the configured timeout |
BLOCKED_PROPERTY | Access to __proto__, constructor, or prototype |
PROPERTY_NOT_ALLOWED | Property not in allowedProperties whitelist |
PROPERTY_DENIED | Property is in deniedProperties blacklist |
MAX_DEPTH | Expression nesting exceeded maxDepth |
MAX_ARRAY_LENGTH | Array size exceeded maxArrayLength |
code. Message text can change more easily than the exported error types.
Load the string stdlib when you need text cleanup, normalization, search, and formatting inside pipelines.
| Transform | Example | Result |
|---|---|---|
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
slice() or includes(), you can also use the core method-call syntax shown earlier.
Load the array stdlib when your expressions need collection work: counting, searching, projection, deduping, and simple aggregation.
| Transform | Example | Result |
|---|---|---|
count | selectedRegions |> count | 3 |
first | planTiers |> first | "starter" |
last | releaseStages |> last | "production" |
reverse | approvalSteps |> reverse | ["legal", "finance", "ops"] |
flatten | tagGroups |> flatten | ["billing", "finance", "ops"] |
unique | regions |> unique | ["gb", "us", "de"] |
join(sep) | invoiceIds |> join(", ") | "INV-1, INV-2" |
sort | priorityScores |> sort | [1, 2, 3] |
sort sorts numbers numerically and falls back to string comparison for other values.
These accept lambda predicates as arguments for filtering, mapping, and searching.
| Transform | Example | Result |
|---|---|---|
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
Load the math stdlib for rounding, clamping, and summarizing numeric values in pipelines.
| Transform | Example | Result |
|---|---|---|
round | averageRating |> round | 4 |
floor | monthlySeats |> floor | 3 |
ceil | storageUnits |> ceil | 4 |
abs | balanceDelta |> abs | 42 |
sum | orderTotals |> sum | 60 |
avg | orderTotals |> avg | 20 |
clamp(min, max) | requestedDiscount |> clamp(0, 25) | 25 |
| Function | Example | Result |
|---|---|---|
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
sum/avg expect arrays of numbers. If your input is textual, convert it first with toNumber or a custom transform.
Load the types stdlib when the input shape is mixed and you need defensive checks or simple conversions.
| Transform | Example | Result |
|---|---|---|
isString | customer.email |> isString | true |
isNumber | order.total |> isNumber | true |
isArray | cart.items |> isArray | true |
isNull | customer.nickname |> isNull | true |
toBool | featureFlag |> toBool | true |
toNumber | "42.50" |> toNumber | 42.5 |
toString | invoiceNumber |> toString | "1042" |
Type checks are useful when the same field may arrive in different shapes:
Load the dates stdlib when you need current timestamps, UTC formatting, or day differences between timestamps.
| Type | Name | Example | Result |
|---|---|---|---|
| Function | now() | now() | Current Unix timestamp in milliseconds |
| Transform | formatDate(fmt) | 1704067200000 |> formatDate("YYYY-MM-DD") | "2024-01-01" |
| Transform | diffDays(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).
Load every standard library module at once - for quick prototyping or when you want everything available.
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.
Plugins are the packaging layer for your expression API. Use them to ship a cohesive set of transforms and functions for one domain.
A plugin is a function that receives a Bonsai instance and registers whatever that domain needs.
Apply plugins during setup, not in the middle of request handling. That keeps the instance predictable and the caches warm.
| Good plugin habits | Why they matter |
|---|---|
| Keep a plugin focused on one domain | Easier to explain, test, and compose |
| Validate inputs inside transforms/functions | Expression authors get clearer runtime errors |
| Prefer stable names over clever names | Expressions become part of your product surface |
| Register plugins once at startup | Avoids behavioral drift across requests |
Here is a practical plugin for formatting and calculating prices:
Bonsai constrains the expression language. It is suitable for safe expression evaluation, but your own registered extensions still run as normal host JavaScript.
Expressions cannot access the global scope, import modules, or reach dangerous prototype properties.
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.
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.
Beyond the configurable property restrictions, Bonsai applies several layers of protection automatically:
| Protection | What it does |
|---|---|
| Own-property-only lookup | Root identifiers are resolved via Object.hasOwn(), so context prototype chains cannot leak inherited properties into expressions. |
| Null-prototype object literals | Objects created inside expressions (e.g., { a: 1 }) use Object.create(null), preventing prototype pollution through expression-constructed objects. |
| Receiver-aware method validation | Method 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 bypass | Canonical numeric array indices (e.g., items[0]) automatically bypass allow/deny lists, so you don't need to whitelist numeric strings. |
| Sync Promise guard | evaluateSync() detects Promise return values from transforms, functions, and methods, and throws an actionable BonsaiTypeError naming the offending call and suggesting evaluate(). |
| Scenario | Recommended posture |
|---|---|
| Trusted internal expressions | Defaults are often fine, but still keep custom plugins small and explicit. |
| User-authored business rules | Use an allowlist, set all resource limits, validate before save, and compile only accepted expressions. |
| Higher-risk multi-tenant environments | Use the Bonsai limits plus worker/process isolation for stronger containment. |
Protect against resource exhaustion with timeout, maxDepth, and maxArrayLength.
| Concern | What to know |
|---|---|
| Custom transforms/functions | They run as normal host JavaScript. Bonsai does not sandbox code you register yourself. |
| Timeouts | Timeout checks are cooperative during evaluator traversal. They do not forcibly interrupt arbitrary synchronous host code. |
| Async callbacks | Async time is checked at awaited boundaries, not by cancellation of the underlying I/O. |
| Hard isolation | If you need a stronger boundary, run evaluation in a worker or separate process. |
allowedProperties, set all three limits, and treat every custom plugin as trusted application code.
The fastest path is a reused instance, compiled hot-path expressions, and sync execution when your extensions do not need promises.
| Need | Best choice |
|---|---|
| One quick sync evaluation | evaluateSync() |
| Repeated hot-path evaluation | compile() once, then compiled.evaluateSync() |
| Async plugin or function call | evaluate() or compiled.evaluate() |
| Small bundle surface | Import only the stdlib modules you actually use |
| Practice | Why it helps |
|---|---|
| Create one instance and reuse it | Keeps the per-instance caches warm and avoids repeated setup work. |
| Compile repeated expressions | Avoids 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 need | Keeps bundle size and startup overhead down. |
Use clearCache() sparingly | Clearing caches is useful for churny workloads, but it throws away warm-state performance. |
Measured with vitest bench on Apple Silicon (M-series). Treat these as point-in-time guidance, not part of the API contract.
| Expression | Example | ops/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 |
| Scenario | Speedup |
|---|---|
| Default usage (parse + evaluate) | 88x faster |
| Pre-compiled literal | 3.2x faster |
| Pre-compiled comparison | 5.6x faster |
Run benchmarks yourself: bunx vitest bench
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.
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.