A tiny, CSP-safe expression language for JavaScript. ~2.0KB min+compressed, one tiny dependency.
Evaluates expressions like user.age > 18 and "admin" in user.roles against data you provide, without running them as JavaScript. xprsn parses each expression into a chain of plain closures, so there is no eval and no new Function.
That makes it a fit wherever the expression is written by someone other than you — a rule in a form builder, a filter in a query UI, a formula in a spreadsheet cell, a condition on a workflow step — and especially where a strict Content Security Policy rules out the usual new Function shortcut.
Contents
- Install
- Usage
- Is xprsn the right tool?
- More than one expression?
- Syntax
- Recipes
- API
- Safety
- Content Security Policy
- Environments
- Embedding xprsn
- Contributing
- License
Install
Node.js 22 or newer, ESM only. TypeScript declarations ship with the package; nothing extra to install.
Usage
import { compile, evaluate } from "xprsn"; // One-shot: evaluate("items[0].price * qty > 100", { items: [{ price: 60 }], qty: 2 }); // => true // Compile once, evaluate many times: const isAdmin = compile('user.age > 18 and "admin" in user.roles'); isAdmin({ user: { age: 30, roles: ["admin"] } }); // => true isAdmin({ user: { age: 16, roles: [] } }); // => false // Custom functions (third argument of evaluate, second of compile): evaluate('lower(name) == "robin"', { name: "ROBIN" }, { lower: (s) => s.toLowerCase() }); // => true
Expressions read only from the values object you pass and call only the functions you register. Anything else — globals, require, the DOM — is simply not reachable.
Is xprsn the right tool?
xprsn evaluates one expression against one values object and returns one value. There are no statements, no local variables, no loops, and no I/O. That is the whole design, and it is worth checking against your problem before you install anything.
It fits when:
- Expressions come from your users, and storing them as strings in a database or config file is the natural thing to do.
- You'd otherwise reach for
new Function, and either can't (strict CSP, a runtime without string-to-code) or would rather not. - The people writing expressions are not programmers, so the syntax has to be typeable and forgiving — a missing key reads as
nullrather than crashing. - Bundle size is a real constraint. The whole language is about 2KB.
Look elsewhere when:
- You need a scripting language — variables, assignment, loops, user-defined functions. Expressions cannot express those, and the multi-step recipe below is a deliberate ceiling, not a stepping stone.
- You want rules stored as structured data rather than text, so a visual builder can round-trip them without parsing. xprsn's input is a string.
- You control both ends. If nobody but you writes the expressions, plain JavaScript is faster, smaller, and better tooled.
- You need a sandbox. xprsn closes the route from an expression to the
Functionconstructor; it does not limit what your own registered functions and exposed methods do once called. See SECURITY.md. - You need CommonJS, or Node older than 22. See Environments.
More than one expression?
An expression produces a single value. Two sibling packages carry the same closure-compiling approach further, and one of them may be closer to what you're actually after:
- sjabloon — a template engine, if you need text rather than a value:
{{ expr }}interpolation with HTML escaping,{{#if}}/{{#elif}}and{{#each}}blocks, and any xprsn expression inside every tag. About 1KB on top of this package. - padvinder — a JSONPath engine, if you need to select many nodes out of a document rather than compute one value. Filter evaluation is the part of JSONPath that has produced real code-injection CVEs elsewhere; padvinder parses filters to closures with no route to code execution, and passes the full RFC 9535 compliance suite.
All three parse to closures and are safe under the same CSP terms. Their only runtime dependencies are each other and waarmerk, the located-diagnostic module they share.
Syntax
| Category | Syntax |
|---|---|
| Literals | 42, 4.2, .5, 1e3, "double", 'single', true, false, null |
| Arrays | [1, 2, 3] |
| Hashes | {"key": value}, {key: value} |
| Arithmetic | + - * / % ** |
| Concatenation | "id-" ~ n (string concat; coerces both sides) |
| Comparison | == != < > <= >= (strict: 1 == "1" is false) |
| Logical | and && or || not ! (with short-circuiting) |
| Membership | "admin" in roles (arrays: includes; strings: substring; objects: own keys only) |
| Ternary | a ? b : c, and the a ?: b shorthand |
| Null coalescing | a ?? b, chains as a ?? b ?? c |
| Access | user.name, user["name"], items[0], items[i + 1] |
| Null-safe access | user?.name, items?.[0], name?.toUpperCase() |
| Method calls | name.toUpperCase(), items.indexOf(2) |
| Functions | lower(name), resolved only from the registry you pass in |
| Lambdas | sum(rows, r => r.price) (single param; a per-item function for host reducers) |
| Identifiers | letters, digits, _, and $ / @ (e.g. $price, @.total) |
==/!= are strict (JS loose equality is a footgun). ~ joins its sides as strings (1 ~ 2 is "12") and binds looser than arithmetic but tighter than comparison, so "total: " ~ a + b joins the sum.
Absence reads as null: an unknown variable or a missing property is null (not undefined), so x == null is the natural "is it there?" test. Present null/0/false/"" are untouched, and registry function return values are left as-is. Reading through a null base still throws, so use ?.: a?.b yields null on a nullish base and guards each step on its own. Chain it at every link that can be null: a?.b?.c. To keep the package tiny, xprsn leaves out matches, ranges (..), and bitwise operators.
$ and @ are ordinary identifier characters, so a variable can be named $ or @. They read through the same guard as any other name, and matter most to hosts that stack nested scopes — see Embedding xprsn.
Recipes
Multi-step expressions
Expressions have no local variables. When a calculation needs intermediate results, split it into named steps and feed each result back in as a variable for the next expression:
const steps = [ ["subtotal", "price * qty"], ["discount", "subtotal >= 100 ? subtotal * 0.1 : 0"], ["total", "subtotal - discount + shipping"], ].map(([name, expr]) => [name, compile(expr)]); function run(values) { const ctx = { ...values }; for (const [name, fn] of steps) ctx[name] = fn(ctx); return ctx; } run({ price: 60, qty: 2, shipping: 5 }); // => { price: 60, qty: 2, shipping: 5, subtotal: 120, discount: 12, total: 113 }
Each step compiles once. The steps are plain data, so you can store them in a database or config file and let users edit the whole calculation.
Aggregates and per-item computation
An expression computes a single value; walking a collection is the host's job. An arrow lambda x => body bridges the two. It compiles to a function value that a registry function calls once per element, so iteration stays in your code: the reducer decides how to combine the results and where to reset.
const reducers = { sum: (rows, f) => rows.reduce((total, row) => total + f(row), 0), }; evaluate( "sum(orders, order => order.price * order.qty)", { orders: [ { price: 20, qty: 2 }, { price: 5, qty: 4 }, ], }, reducers, ); // => 60
A lambda takes one bare parameter (no parentheses) and its body is any expression. That body parses to closures like everything else, so every read still passes through the same guard. A lambda adds no route to code execution: order => order.constructor throws just as x.constructor does. The parameter binds in a child scope, so it shadows an outer variable of the same name and drops out of names:
compile("sum(orders, r => r.price * tax)", reducers).names; // => ['orders', 'tax']
Because the reducers are yours, you decide what they do: sum, count, avg, any, map, or a running total that keeps state between calls. xprsn only hands each one a per-item function. It never iterates for you, and a lambda cannot call itself (f => f(f) is a compile-time error), so an expression can't recurse into an infinite loop.
Caching compiled expressions
There is no built-in parse cache. If you evaluate the same expressions repeatedly, memoize compile:
const cache = new Map(); const cached = (expr) => cache.get(expr) ?? cache.set(expr, compile(expr)).get(expr);
API
compile(expression, functions?, options?)
Parses the expression and returns an evaluator function (values?) => result. Malformed input and unknown function names throw a SyntaxError at compile time.
The evaluator also carries names: the variables the expression reads, deduplicated. Property names, hash keys, and registry functions don't count; only the roots do.
const fn = compile("user.age > 18 and (discount ?? 0) > 0"); fn.names; // => ['user', 'discount']
When expressions come from your users, names is how you check a rule against a schema before saving it (fn.names.every(n => n in schema)), or how you find which stored rules read a field you're about to rename. In the multi-step pattern above, each step's names are its dependencies.
Evaluators carry two further properties aimed at hosts building editors and validators: reads, every root-name read with its span, and functions, the registry functions the expression calls. options.bound shapes what names reports.
evaluate(expression, values?, functions?)
Shorthand for compile(expression, functions)(values). Compiles every call, so prefer compile in a hot path.
signatures(functions?)
Describes a registry — one { name, arity, doc } per entry — for editors and function reference docs. See EMBEDDING.md.
Error diagnostics
Errors produced by xprsn keep their SyntaxError or TypeError class and expose three machine-readable properties:
code: a stable category;start: the zero-based source offset;end: the exclusive source offset.
The codes are XPRSN_SYNTAX, XPRSN_UNKNOWN_FUNCTION, XPRSN_TOO_DEEP, XPRSN_NULL_BASE, XPRSN_BLOCKED_KEY, and XPRSN_NOT_CALLABLE. End-of-input syntax errors use an empty span at the expression length. A computed property failure spans the bracket operation, because its runtime key may not occur literally in the source.
Together they are enough to underline the offending characters back to whoever wrote the expression:
import { compile, isDiagnostic } from "xprsn"; try { compile("price * (qty"); } catch (error) { if (!isDiagnostic(error)) throw error; console.log(error.code, error.start, error.end); // XPRSN_SYNTAX 12 12 }
Errors thrown by registered functions, getters, methods, or value coercion hooks are host errors. xprsn passes them through unchanged and does not attach diagnostic fields. isDiagnostic(error) is how you tell the two apart; it authenticates by identity rather than by shape, which has consequences worth knowing if you embed xprsn — see EMBEDDING.md.
Safety
Expressions can only read the data you pass in:
- Every property read (
a.b,a[b], method lookup, and bare variable names) goes through a guard that rejects__proto__,constructor, andprototype. This blocks thex.constructor.constructor(...)route toFunction. - Hash literals are built on null-prototype objects, so
{"__proto__": …}is plain data and cannot polluteObject.prototype. inon objects checks own properties only; inherited properties are not visible.- There are no assignment operators, so expressions cannot modify your data.
- Functions resolve from the registry you provide, at compile time.
- Lambdas (
r => r.price) compile to function values, but an expression can't call one; only your registry functions can. Reads inside a lambda still go through the guard, so they open no route toFunction.
Expressions can still call methods on the values you expose (user.delete(), say, if you pass such an object), so only pass data you are comfortable handing over. SECURITY.md has the checklist to work through before accepting expressions from people you don't trust, and the process for reporting a vulnerability.
Content Security Policy
This package works under a strict CSP such as:
Content-Security-Policy: script-src 'self'
It needs no unsafe-eval because the compiler only composes arrow functions that already exist in the shipped source; it never turns expression text into JavaScript. The test suite runs under node --disallow-code-generation-from-strings, which throws on any string-to-code construct the same way a strict CSP does, and a test checks the source for such constructs. The library never touches the DOM, so you don't need a Trusted Types policy.
npm run test:browser serves lib/ to Playwright Chromium under this policy, including blocked-key reads that should throw. The run checks that the library itself works under CSP. It does not sandbox registry functions or host objects you pass in.
Environments
Node.js 22 and newer, ESM only. Browser use is supported through a standards-based ESM bundler in environments supporting ES2024. Direct <script> globals, UMD, and CommonJS builds are not provided.
Shipping CommonJS alongside ESM would put two copies of the core in any process that mixed require and import. Each copy would have its own diagnostic identity, so isDiagnostic would return false across the seam.
TypeScript declarations are hand-written and ship in the package; npm run check runs attw against them.
Embedding xprsn
If you compile xprsn source out of a larger document — a cell in a report, a field in a form, a rule in a workflow builder — EMBEDDING.md covers the surface built for that: expression introspection for validators and editors, registry signatures, diagnostic identity, relocating a fault into your own coordinates, and nested scopes with @ and $.
Contributing
git clone https://github.com/getquario/xprsn.git cd xprsn npm install git config core.hooksPath .githooks # enable the commit-msg hook npm run check
npm run check is the local gate: formatting, lint, dead-code and dependency checks, the size budget, the unit and type suites, the browser CSP run, and the fuzz regression corpus. It is the same gate CI runs, so a green check locally means a green pull request.
Conventions for this repo — architecture, semantics that look like bugs if you tidy them, and the commit format — live in AGENTS.md.
License
Copyright 2026 Robin van der Vleuten
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.