Skip to content

Migrating from 0.x ​

Bonsai 1.0 is a redesign. The expression syntax is still JavaScript-like, but the language now has a static checker, a single function namespace with every built-in included, strict booleans, one absent value (null), and value equality. The JavaScript API is built around an immutable environment instead of a mutable instance with plugins.

Most expressions need small, mechanical changes, and the checker finds most of them for you. Some changes are silent: the expression still runs but gives a different result. They are listed in Results that changed; review them before you switch.

Before you start ​

  • Node.js 22 or newer. The package is ESM. require('bonsai-js') works on Node versions that can load ES modules synchronously (22.12 and newer).
  • Every built-in is included. Remove bonsai-js/stdlib imports and .use(...) calls.
  • Declaring variables makes the environment strict. With variables, an undeclared name is a check error instead of reading the context. Pass strict: false to keep reading undeclared names as any, or declare everything the expressions read.

Expressions ​

0.x1.xNotes
name |> trim |> uppername.trim().toUpperCase()|> is reserved and is a syntax error in 1.x.
a |> f(b)a.f(b) or f(a, b)Every function is also a method.
upper, lowertoUpperCase, toLowerCaseJavaScript names.
flattenflat
x |> isString, x |> isNumber, x |> isArraytype(x) == "string", "number", "list"type() returns the kind of any value.
x |> isNullx == nullAlso true when x is missing.
x |> toBoolan explicit test, for example x != "" or x > 0Booleans are strict.
a |> diffDays(b) (epoch milliseconds)round(abs(inDays(timestamp(a) - timestamp(b))))Subtracting timestamps gives a signed, fractional duration. See Results that changed.
now() (epoch milliseconds)now() (a timestamp)Compare with now() - t > days(30).
ts |> formatDate("YYYY-MM-DD")formatDate(timestamp(ts), "yyyy-MM-dd")Takes a timestamp, not epoch milliseconds (timestamp(ts) converts them). Tokens are yyyy MM dd HH mm ss SSS (and more); an optional time zone argument is accepted.
xs |> filter, xs |> some, xs |> every (no argument, by truthiness)xs.filter(. != null), flags.some(.), flags.every(.)Lambdas are required. . alone works for lists of booleans; for other lists write the test.
undefinednullThere is no undefined literal.
substring(i, j)slice(i, j)The same for 0 <= i <= j. slice does not swap reversed arguments and counts negative positions from the end. See Results that changed.
charAt(i)at(i)at gives null past the end and counts negative positions from the end.
concat+
toSorted(), toReversed()sort(), reverse()They never mutate, so the to prefix is not needed. sort() orders numbers numerically.
toSpliced(i, n)xs.slice(0, i) + xs.slice(i + n)
with(i, v)xs.slice(0, i) + [v] + xs.slice(i + 1)
charCodeAt(i)noneDeclare a host function if you need code points.
+x (unary plus)toNumber(x)There is no unary plus.
indexOf(s, from), includes(s, from), startsWith(s, from)let i = x.slice(from).indexOf(s); i < 0 ? -1 : i + from, slice(from).includes(s), slice(from).startsWith(s)The position argument is gone; the checker reports the extra argument.
toString(16), split() with no separatora host function; [s]Radix and whole-string split are gone; the checker reports both.
slice(), at(), toFixed() with no argumentslice(0), at(0), toFixed(0)The argument is required; the checker reports the call.
bonsai
round(abs(inDays(timestamp(a) - timestamp(b)))) // => 3
xs.slice(0, 1) + xs.slice(2) // => [1, 3]
xs.slice(0, 1) + [9] + xs.slice(2) // => [1, 9, 3]
flags.some(.) // => true
items.filter(. != null) // => [1, 2]

Results that changed ​

These expressions give different results in the two versions. The table writes them in 1.x syntax; in 0.x, functions such as round, avg, toNumber, and toString were pipe transforms (-2.5 |> round). Most run without any error in 1.x, so the checker cannot flag them; search stored expressions for the functions involved. Rows whose 1.x column is a check error are the exception, and env.check() finds them.

Expression0.x1.xTo keep the old result
round(-2.5)-2-3Halves round away from zero, as toFixed does.
round(2.345, 2)2 (digits ignored)2.35Drop the second argument.
(1.005).toFixed(2)"1.00""1.01"Rounds the decimal value as written, not its binary approximation.
avg([])0nullavg(xs) ?? 0
unique on lists of maps or listscompares by referencecompares by value (==)
diffDays(a, b)whole days, always positiveinDays(a - b) is fractional and signedround(abs(inDays(a - b))). At exactly half a day this rounds up in both orders; 0.x rounded 3.5 days apart to 3 when a was earlier and to 4 when it was later.
`${x}` with x null"null"""`${x ?? "null"}`
`${x}` with x undefined or missing"undefined"""
`${date}`local Date.toString() textISO-8601 in UTCformatDate(date, pattern, zone)
`${list}`, `${map}`"1,2", "[object Object]"TYPE_ERRORjoin(list, ",")
toString(null)"null"""
toNumber("")0INVALID_ARGUMENTtry(toNumber(s), 0)
toNumber("abc")NaNINVALID_ARGUMENTtry(toNumber(s), null)
toNumber(true), toNumber(null)1, 0check errorb ? 1 : 0, x ?? 0
sort on a list mixing numbers and textsorted numbers firstTYPE_ERROR (a check error when the types are known)Sort one kind at a time.
x / 0, x % 0Infinity, NaNDIVISION_BY_ZEROtry(x / y, 0)
"a" + 1, null + 1"a1", 1TYPE_ERROR`a${1}`, (x ?? 0) + 1
x == null with x missingfalsetrue
[1] == [1]falsetrue
null < 1truefalse
0 || "d""d"TYPE_ERRORx ?? "d" for defaults, or an explicit test
nums.toSorted() with nums = [10, 9, 1, 100][1, 10, 100, 9] (text order, as JavaScript)[1, 9, 10, 100]Sort text with map(toString(.)).sort().
ll.includes([1]), ll.indexOf([2]), [1] in ll, objs.includes({ a: 1 })false, -1, false, false (by reference)true, 1, true, true (by value)
d1 == d2, two Dates at the same instantfalse (different objects)true
"abc".replace("b", "$&$&")"abbc" ($&, $$, and $`/$' patterns expand)"a$&$&c" (replacement text is literal)Build the text: s.replace("b", `${x}${x}`)
"hello".substring(3, 1) → slice(3, 1)"el" (arguments swapped)""slice(min(i, j), max(i, j))
"hello".substring(-2) → slice(-2)"hello" (negative treated as 0)"lo" (counts from the end)slice(max(i, 0))
"hello".charAt(10) → at(10)""nullat(i) ?? ""
"hello".charAt(-1) → at(-1)"""o" (counts from the end)i < 0 ? "" : at(i) ?? ""
A host Map, Set, or RegExp in the contextread through its prototype: m.size, re.source, even m.get as a functionan opaque value; reading any property is a TYPE_ERRORPass plain objects, arrays, and values (m.size as a number).
bonsai
round(-2.5) // => -3
round(2.345, 2) // => 2.35
(1.005).toFixed(2) // => "1.01"
avg(xs) ?? 0 // => 0
[{ a: 1 }, { a: 1 }].unique().length // => 1
`${x}` // => ""
`${d}` // => "2026-01-02T03:04:05.000Z"
try(toNumber(""), 0) // => 0
5 % 0 // error: DIVISION_BY_ZERO

Semantics that changed ​

== null includes missing values. In 0.x == was JavaScript ===, so a missing property (undefined) was not equal to null. Now every absent value is null:

bonsai
user.middleName == null // => true

== compares by value. Lists and maps compare deeply; in 0.x they compared by reference.

bonsai
[1, 2] == [1, 2] // => true

Booleans are strict. &&, ||, !, and ?: accept booleans and null (as false). In 0.x they followed JavaScript truthiness and a || b returned a itself. Replace name || "Anonymous" with name ?? "Anonymous", and items.length && ... with items.length > 0 && ....

bonsai
name ?? "Anonymous" // => "Anonymous"
items.length > 0 && items[0] == 1 // => false
name || "Anonymous" // error: TYPE_ERROR

No coercion. "a" + 1 and null + 1 are errors. Use a template for text (`a${1}`) and ?? for defaults.

No NaN or Infinity. Division and remainder by zero are DIVISION_BY_ZERO errors instead of Infinity and NaN, and any computation that would produce a non-finite number is a NON_FINITE error. Wrap with try(expr, fallback) if a default is wanted.

Comparisons with null are false. null < 1 is false, not true as in JavaScript.

Dates are timestamps. A Date in the context is a timestamp, and arithmetic uses durations (days(3), hours(1)). Numbers are not treated as dates; convert epoch milliseconds with timestamp(ms).

Lambdas are type-directed. . binds to the nearest enclosing argument whose parameter is a function, so an expression such as items.filter(.price > max(.bonus, 10)) now works: .bonus belongs to the item. In 0.x the shorthand could not be passed into another call.

Only plain data is navigable. Plain objects and class instances are read through their own properties. In 0.x, built-in objects were also read through their prototypes (m.size, re.source). Now Map, Set, RegExp, promises, typed arrays, errors, and boxed primitives are opaque: they can be compared and passed to host functions, but reading a property of one is a TYPE_ERROR. Convert them to plain objects and arrays before evaluating.

API ​

0.x1.x
const expr = bonsai(options)const env = bonsai({ variables, strict, functions, libraries, limits, cacheSize, clock, validateContext })
bonsai<AppCtx>()The context type is inferred from variables. Without variables, the context is any object, so a value typed by an interface (const ctx: AppCtx = ...) is accepted as is.
expr.use(strings).use(arrays), bonsai-js/stdlibNothing to import: every built-in is always available.
expr.addFunction('f', fn)bonsai({ functions: { f: fn({ params, returns, run }) } })
expr.addContextFunction('f', (ctx, ...) => ...)fn({ params, returns, call: true, run: (call, ...args) => ... }), reading call.context, or withContext<Ctx>() for a typed context.
expr.addTransform('f', fn)A host function. Call it as x.f().
expr.removeFunction(), expr.removeTransform()Environments are immutable: create an environment without the function.
Plugins (BonsaiPlugin)A Library: { name, functions, variables }, passed as libraries: [lib].
expr.evaluateSync(src, ctx)env.evaluateSync(src, ctx) (unchanged)
expr.evaluate(src, ctx)env.evaluate(src, ctx). Only functions declared async: true are awaited; any other function returning a promise is an error.
expr.compile(src)env.compile(src, { expect }) returns a checked Program with source, ast, type, async, warnings, and references.
expr.validate(src) returning { valid, errors, ast, references }env.check(src) returns { ok, type, diagnostics } and never throws. Each diagnostic has code, message, severity, start/end offsets, and a 1-based position ({ line, column }). For the syntax tree, call env.parse(src); for references, env.compile(src).references.
result.references.identifiers, .functionsprogram.references.variables, .functions. transforms is gone: transforms are functions.
evaluateExpression(src, ctx)bonsai().evaluateSync(src, ctx)
allowedProperties, deniedProperties, getPolicy()Removed. Pass only the data expressions may read. Declare variables with t to catch unknown names and fields at check time.
timeout, maxDepth, maxArrayLength, maxStringLength optionslimits: { timeout, maxDepth, maxListLength, maxStringLength, ... }. See Limits.
cacheSize option, clearCache()cacheSize option (unchanged). There is no clearCache(); create a new environment to start with an empty cache.
listFunctions(), hasFunction(), listTransforms(), hasTransform(), isContextFunction()env.listFunctions() and env.describeFunction(name) (undefined when the function does not exist).
bonsai-js/autocomplete, createAutocomplete(expr, { context }), complete() returning Completion[]bonsai-js/service, createLanguageService(env). complete(source, offset) returns { start, end, items }, computed from types rather than by evaluating a sample context.
tokenize, parse, compile exportsparse(src) (or env.parse(src)) returns the 1.0 syntax tree, whose node types are renamed; print(tree) turns it back into source. tokenize and compile are removed: use env.compile(src).
formatError(e), formatBonsaiError(e)error.formatted

TypeScript types ​

0.x1.x
BonsaiInstanceEnvironment
BonsaiOptionsEnvironmentOptions
CompiledExpressionProgram
ValidationResultCheckResult
FunctionFn, ContextFunctionFn, TransformFnHostFunction, created with fn()
BonsaiPluginLibrary
ASTNodeNode
ExpressionReferences, PolicySnapshot, ResolveResult, Token, TokenType, InferredTypeNameRemoved.

Syntax tree node types were renamed and regrouped. Code that walks trees needs updating:

0.x node1.x node
NumberLiteral, StringLiteral, BooleanLiteral, NullLiteralLiteral
UndefinedLiteral, PipeExpressionRemoved.
TemplateLiteralTemplate
IdentifierVariable (context variables), Local (let bindings and lambda parameters)
MemberExpression, OptionalMemberExpressionMember (a.b), Index (a[k]), each with an optional flag
CallExpressionCall, for both f(x) and x.f(), with the receiver as the first argument
BinaryExpression, UnaryExpression, ConditionalExpressionBinary, Unary, Conditional
ArrayLiteral, ObjectLiteral, ObjectProperty, SpreadElementList, Map, Entry, Spread
LambdaAccessor, LambdaIdentity, LambdaExpressionIt (the implicit .) and Lambda
noneLet, Has, Try

Limits ​

0.x1.x
timeoutlimits.timeout
maxDepth (evaluation depth, default 100)limits.maxDepth limits syntax nesting (default 128). Nesting of values is limits.maxValueDepth (default 64).
maxArrayLengthlimits.maxListLength
maxStringLengthlimits.maxStringLength
nonemaxSourceLength, maxNodes, maxSteps, maxPatternLength. See Limits.

Errors ​

Every error is a BonsaiError with a stable code, source, span ({ start, end }), 1-based position, and formatted text. Check error.code rather than the class.

0.x1.x
ExpressionErrorBonsaiSyntaxError (code SYNTAX)
BonsaiTypeErrorBonsaiCheckError (code CHECK, with diagnostics) at compile time, BonsaiRuntimeError (TYPE_ERROR, NO_OVERLOAD, ...) at run time
BonsaiReferenceError (unknown function)an UNKNOWN_FUNCTION diagnostic in a BonsaiCheckError
BonsaiSecurityError TIMEOUTBonsaiLimitError TIMEOUT
BonsaiSecurityError MAX_DEPTHBonsaiLimitError TOO_DEEP (syntax nesting) or VALUE_DEPTH_LIMIT (nested data)
BonsaiSecurityError MAX_ARRAY_LENGTHBonsaiLimitError LIST_LIMIT
BonsaiSecurityError MAX_STRING_LENGTHBonsaiLimitError STRING_LIMIT
BonsaiSecurityError BLOCKED_PROPERTYSYNTAX for a literal key, BLOCKED_PROPERTY for a computed one
BonsaiSecurityError PROPERTY_NOT_ALLOWED, PROPERTY_DENIED, METHOD_NOT_ALLOWEDRemoved with the allow and deny lists.
error.start, error.end, error.locationerror.span
error.rawMessageerror.message
error.suggestionIncluded in error.message ("did you mean ...")
transform, expected, received, identifier, kind fieldsRemoved. The message names the function and the types.

Two new runtime codes separate host failures: HOST_ERROR when your function throws (an expression may recover with try), and HOST_CONTRACT when it returns a value that does not match its declared type or returns a promise without async: true (never caught by try). See Errors.

Host functions, before and after ​

ts
import { bonsai, fn, t } from 'bonsai-js'

// 0.x:
//   const expr = bonsai()
//   expr.addFunction('discount', (total, rate) => total * (1 - rate))
//   expr.addContextFunction('hasPermission', (ctx, action) => ctx.perms.includes(action))

const env = bonsai({
  functions: {
    discount: fn({
      params: [t.number(), t.number()],
      returns: t.number(),
      run: (total, rate) => total * (1 - rate),
    }),
    hasPermission: fn({
      params: [t.string()],
      returns: t.boolean(),
      call: true,
      run: (call, action) => (call.context.perms as string[]).includes(action),
    }),
  },
})

env.evaluateSync('hasPermission("admin") ? total.discount(0.1) : total', {
  perms: ['admin'],
  total: 200,
}) // => 180

Declared parameter and result types mean the checker rejects discount("a", 1) before it runs, arguments are validated before your code is called, and a result of the wrong type is a HOST_CONTRACT error instead of propagating. In 0.x an async function could be called from evaluate() without being declared; in 1.x add async: true, and evaluateSync() rejects expressions that call it before any host code runs.

Suggested order ​

  1. Upgrade to Node.js 22 or newer.
  2. Replace the instance setup with bonsai({ ... }) and move functions into functions with fn.
  3. Run env.check() over your stored expressions. Syntax errors point at |> and removed names; check errors point at type problems and undeclared names.
  4. Rewrite pipes as method calls and rename functions using the tables above.
  5. Review expressions that relied on truthiness (|| for defaults, numbers or strings as conditions), on undefined, and on the results that changed.
  6. Add variables declarations with t to get type errors and editor completions, and validateContext: true if the context comes from outside your code.