npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

xprsn

v0.12.0

Published

Tiny, CSP-safe expression language for JavaScript. No eval, no new Function.

Readme

xprsn

A tiny expression language for JavaScript. It evaluates expressions your users write — user.age > 18 and "admin" in user.roles — against data you pass in, without ever running them as JavaScript.

Each expression is parsed into a chain of plain closures, so there is no eval and no new Function anywhere in the library. Because nothing is turned into JavaScript source, xprsn works unchanged under a strict Content Security Policy, where the usual new Function shortcut is blocked outright. 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.

  • Tiny. 2.4 kB minified and brotlied, including its one dependency.
  • CSP-safe. Runs under script-src 'self'. A Playwright suite loads the published file under that policy, and the tests run on node --disallow-code-generation-from-strings.
  • Hardened. __proto__, constructor and prototype are rejected on every read, closing the x.constructor.constructor(...) route to Function. 100% branch coverage, plus three fuzz targets.
  • Forgiving. A missing key or unknown variable reads as null instead of crashing — the syntax is meant for people who don't write code.
  • Made for editors. Every error carries a stable code and a start/end span to underline, and every compiled expression lists the variables it reads.
  • Fast. ~9.8M evaluations/sec once compiled (npm run bench, Node 24 on arm64).
import { evaluate } from "xprsn";

evaluate('user.age > 18 and "admin" in user.roles', {
  user: { age: 30, roles: ["admin"] },
});
//=> true

xprsn is built by the team behind Quario, a declarative reporting engine for JavaScript that renders JSON report definitions to HTML, PDF, workbooks, and Word — without eval.

Contents

Install

npm install xprsn

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 null rather than crashing.
  • Bundle size is a real constraint. The whole language is 2.4 kB minified and brotlied.

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 Function constructor; 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, with one exception: a promise is returned only when the call is the whole expression, because an operator cannot await one. A promise reaching an operator is a TypeError carrying XPRSN_PENDING_VALUE and the call's span. 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, and prototype. This blocks the x.constructor.constructor(...) route to Function.
  • Hash literals are built on null-prototype objects, so {"__proto__": …} is plain data and cannot pollute Object.prototype.
  • in on 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 to Function.

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.