Let users write rules without letting them write JavaScript
You're building the kind of feature where the logic can't all live in your code. A pricing rule marketing wants to tweak without a deploy. A feature flag that should switch on when user.age > 18 and "admin" in user.roles. A filter your users configure themselves on a settings screen.
The condition is data, not code. So you store it as a string and evaluate it when you need it.
The obvious way to run a string like that is new Function or eval:
const rule = 'items[0].price * qty > 100';
const check = new Function('items', 'qty', `return ${rule}`);
check(order.items, order.qty); // works... on your machine
Then it meets production.
Where eval stops being an option
Two things break it.
The first is your Content Security Policy. Ship a strict one, the kind you actually want:
Content-Security-Policy: script-src 'self'
The browser now refuses to run anything built from a string. new Function and eval both need 'unsafe-eval', and adding that back gives up most of what you added the header for. I hit the same wall from the other side when I wrote about passing server data to the client without breaking CSP.
The second problem is trust. The moment a user, or a row in your database, can shape the expression, eval hands them the whole runtime. items.constructor.constructor("return process")() isn't a filter. It's a shell.
So you go looking for a library. expr-eval has been around for years but hasn't seen much attention lately. math.js is excellent and large, and it's aimed at math, not at "is this user an admin over eighteen." I wanted a small core that does one job: compile an expression, evaluate it against data, and let me add my own functions. Nothing fit that shape, so I pulled the piece I'd already written for a commercial project out into its own package.
Meet xprsn
xprsn evaluates expressions against data you hand it, without ever running them as JavaScript. It's about 1.2KB min+gzip, with zero dependencies. Let's see it in action.
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
compile parses the expression once and hands back a function. Call it with different data as often as you like. evaluate is the shorthand for when you only need the answer once.
Custom functions come in as the last argument. They're the extension point I built the whole thing around:
evaluate('lower(name) == "robin"', { name: 'ROBIN' }, { lower: s => s.toLowerCase() });
// => true
Only the functions you pass in are callable. There's no ambient standard library to lock down, because there isn't one.
The syntax fits on a postcard
Enough to write real rules, and deliberately not enough to write a program:
Literals and collections. Numbers like
42and4.2, strings,true/false/null, arrays[1, 2, 3], and hashes{ key: value }.The usual operators. Arithmetic (
+ - * / % **), comparison (== != < > <= >=), and logic in either spelling:and/&&,or/||,not/!.Membership.
"admin" in roleschecks an array's items, a string's substrings, and an object's own keys.Access and calls.
user.name,items[0],items[i + 1], and methods on your data likename.toUpperCase().Ternaries.
a ? b : c, plus thea ?: bshorthand for "a if it's truthy, otherwise b."
One deliberate sharp edge: == is strict. 1 == "1" is false, because JavaScript's loose equality is a footgun you don't want to hand to whoever is editing these rules. To stay tiny, xprsn also leaves out string concatenation, regex matching, ranges, bitwise operators, and null-safe ?.. Most of those you can add back as custom functions, though. Pass a concat or a matches into the registry and call it like any built-in.
const funcs = {
concat: (...parts) => parts.join(''),
matches: (str, re) => new RegExp(re).test(str),
};
evaluate('concat(first, " ", last)', { first: 'Robin', last: 'van der Vleuten' }, funcs);
// => 'Robin van der Vleuten'
evaluate('matches(email, "^[^@]+@[^@]+$")', { email: '[email protected]' }, funcs);
// => true
The regex lives in your JavaScript, not in the expression string. The rule author only chooses which function to call by name, so adding matches back never reopens the door xprsn closed. Every bit of executable code still ships in your source, where a strict CSP is fine with it.
How it dodges eval entirely
Here's the part I find genuinely neat. xprsn never turns your expression text back into JavaScript. It compiles each expression into a tree of small closures that already exist in the shipped source.
Take price * qty. A naive evaluator would stitch together the string "price * qty" and eval it. xprsn parses it and returns a function that closes over two smaller functions:
// Conceptually, `price * qty` compiles to something like:
const left = values => get(values, 'price');
const right = values => get(values, 'qty');
const expr = values => left(values) * right(values);
Every literal becomes a closure that returns a constant. Every property read becomes a closure that reads one key. Operators combine the closures beneath them. Compilation is a precedence-climbing parser walking the tokens once and wiring these functions together. At no point does a string turn into code.
That's what makes it CSP-safe by construction rather than by configuration. There's no 'unsafe-eval' to grant, because nothing ever asks for it. The test suite proves it the hard way: it runs under node --disallow-code-generation-from-strings, which throws on any string-to-code call the same way a strict CSP does, and one test scans the source itself for eval, Function, and friends.
Safe to point at half-trusted input
CSP is only the outer wall. If rules can come from users, the evaluator itself has to refuse the classic escapes.
The famous one is the prototype chain: x.constructor.constructor is Function, which is eval wearing a hat. Every property read in xprsn, whether a.b, a[b], a method lookup, or a bare variable, goes through one guard that rejects __proto__, constructor, and prototype. There is no path from an expression to the Function constructor.
A few more guardrails fall out of the design:
Hash literals are null-prototype objects. So
{ "__proto__": something }is inert data. It cannot polluteObject.prototype.in only sees own keys. Inherited properties stay invisible.
There are no assignment operators. An expression can read your data. It can't change it.
One honest limit: expressions can still call methods on the values you expose. Pass an object with a delete() method and data.delete() is a valid expression. The rule is simple: only hand xprsn data you're comfortable letting the expression touch.
When one line isn't enough
Expressions have no variables, on purpose. When a calculation needs intermediate results, you name the steps and feed each answer into the next:
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, and the steps are plain data. That's the quiet payoff: you can store a whole calculation in a database or a config file and let non-developers edit it, because none of it is code.
When to reach for it
User-defined filters and segments. Let people describe "orders over €100 from the last week" and store the string they wrote.
Feature-flag and targeting conditions.
user.plan == "pro" or user.age > 65, edited in a dashboard instead of a deploy.Pricing, discount, and scoring rules. The logic marketing changes weekly, kept out of your release cycle.
Low-code and config-driven UIs. Anywhere the shape of the logic lives in data and the client evaluates it under a strict CSP.
If you only need arithmetic, math.js is the more complete tool. If you never touch untrusted strings or a strict CSP, a hand-rolled function might be all you want. xprsn is for the overlap: dynamic expressions, half-trusted input, and headers you refuse to loosen.
How far does the closure trick go?
Once you have an evaluator that compiles to closures and never reaches for eval, you start wondering where the trick runs out. So I pushed on it.
A template is just text with expressions punched into it. But almost every runtime template engine compiles each template to a JavaScript function, which is the same new Function a strict CSP forbids. Handlebars without a precompile step, tempura, most of them hit the same wall xprsn was built to avoid.
So I built sjabloon on top of it. It's Dutch for "template." Same idea, one level up: it parses a template into a tree of closures, and every tag inside runs a real xprsn expression.
import { template, render } from 'sjabloon';
// Compile once, render many times:
const greet = template('Hello {{ user.name.toUpperCase() }}!');
greet({ user: { name: 'Robin' } }); // => 'Hello ROBIN!'
// Blocks, expressions, and custom functions:
render(
`{{#each items as it, i}}{{ i + 1 }}. {{ it.name }}: {{ fmt(it.price * it.qty) }}
{{/each}}{{#if total >= 100 and "vip" in user.roles}}Free shipping!{{#else}}Shipping: {{ fmt(5) }}{{/if}}`,
{ items: [{ name: 'Koffie', price: 8, qty: 2 }], total: 120, user: { roles: ['vip'] } },
{ fmt: n => '€' + n.toFixed(2) }
);
The tags are what you'd guess: {{ expr }} interpolates and escapes HTML, {{{ expr }}} skips the escaping, {{#if}}/{{#else}} branches, {{#each list as item, i}} loops with an optional index, and {{! comment }} drops out of the output. Every expr is a full xprsn expression, custom functions included, so the same guards carry straight through. The whole engine is about 0.8KB, roughly 2KB with xprsn along for the ride, and it clears the same node --disallow-code-generation-from-strings test.
I want to be honest about when this is the wrong tool. If you can precompile templates at build time, Handlebars is faster and more capable. sjabloon earns its place only when templates arrive at runtime, like user-edited layouts, CMS fragments, or email bodies, and your CSP won't budge.
Getting started
npm install xprsn
The README has the full syntax table and the safety notes. Import compile and evaluate, hand them your expression and your data, and delete the new Function call you were nervous about.
Closing thoughts
I didn't set out to write an expression language. I had a real feature that needed one, reached for the existing options, and none of them fit the exact shape of tiny, extensible, and safe under a strict CSP. So the reusable core came out of the product and into a package small enough to read in one sitting. Small enough, it turned out, to build on.
That's usually the right moment to open-source something. Not when you imagine it might help someone one day, but when it has already earned its keep somewhere real. Have you had to run user-defined logic without handing over the keys to the runtime? I'd like to hear how you approached it.