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

@laioutr/expression

v1.0.0

Published

Lightweight JSON Expression implementation

Readme

@laioutr/expression

NPM version License: MIT engine engine + default operators engine + all operators

A JSON-based expression language for filter predicates, authorization rules, and conditional UI logic. Expressions are plain JSON, so they can be stored in a database, sent over the wire, and evaluated in a sandbox.

  • Just a single runtime dependency: dequal
  • Tree-shakeable: pay only for the operator sets you import
  • Type-safe operator names and arities at the call site
  • ESM-only, runs in Node 18+, the browser, Deno, and Bun
  • Compatible subset of the JSON Expression spec

Install

pnpm add @laioutr/expression

Filter a list of products

import { JsonExpression, type ExpressionOf } from '@laioutr/expression';
import { defaultOperators } from '@laioutr/expression/operators/default';

const engine = new JsonExpression(defaultOperators);

// "in stock and under 50 USD": the kind of predicate a CMS would store as JSON.
const filter: ExpressionOf<typeof engine> = ['and', ['==', ['get', 'inStock'], true], ['<', ['get', 'price'], 50]];

const products = [
  { name: 'Mug', price: 12, inStock: true },
  { name: 'Lamp', price: 80, inStock: true },
  { name: 'Pencil', price: 3, inStock: false },
];

products.filter((p) => engine.eval(filter, { $root: p }));
// [{ name: 'Mug', price: 12, inStock: true }]

When to use this

Reach for @laioutr/expression when:

  • You store predicates as JSON and evaluate them occasionally against varied inputs.
  • Bundle size and tree-shaking matter; you want only the operator sets you use.
  • You want compile-time validation of operator names and arities at the call site.

Reach for the reference implementation instead when you need a JIT-compiled hot path against the same expression evaluated millions of times.

Evaluation rules

  • Expressions are JSON, so JSON.stringify round-trips them. See Type safety for the typed wrapper.
  • A non-array value evaluates to itself. A single-element array [v] evaluates to v, which lets you store an array literal without it being parsed as an expression ([[1, 2, 3]] evaluates to [1, 2, 3]).
  • Operands are evaluated left to right unless the operator says otherwise. if, and, and or short-circuit.
  • eval(expr) defaults ctx.$root to undefined. With no $root, get throws NotFoundError unless a default value is provided.
  • Aliases (==, &&, +, etc.) and canonical names (eq, and, add) resolve to the same operator. Either form is stable when stored as JSON.

API

class JsonExpression

const engine = new JsonExpression(defaultOperators);

// One-shot evaluation:
engine.eval(expression, { $root });

// Build once, call many:
const prepared = engine.prepare(expression);
prepared({ $root }); // 1.4-1.9× faster per call than eval

new JsonExpression(operators?) accepts an optional OperatorDefinition[] to register at construction. The set is captured in the engine's type so both methods only accept calls to those operators. Pass nothing for an empty engine that rejects all operator calls at the type level.

eval(expr, ctx?) evaluates an expression. ctx.$root is the input data accessible via get / $. Defaults to { $root: undefined }. Type-level validation adapts to how expr is typed: literal input is checked for both name and arity at compile time (recursively); a wide ExpressionOf<typeof engine> is checked for name only, with arity falling back to runtime. See Type safety.

prepare(expr) walks the expression tree once and returns a PreparedExpression (a function (ctx?) => any) that evaluates much faster on subsequent calls. Crossover with eval is at ~3 invocations of the same expression. See When to prepare. Validation is eager: an unknown operator or arity violation in any branch (including unreached if / and / or branches) throws at prepare time, not at first invocation. Same compile-time validation behaviour as eval.

When to prepare

| You're doing... | Use | | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | One-shot evaluation of a stored predicate | eval | | Filtering a list, applying a stored predicate to many records, evaluating in a hot loop | prepare | | Validating a stored expression at deploy time (catch typos in unreached branches) | prepare. Eager validation surfaces issues that lazy eval would miss |

The crossover point is ~3 invocations of the same expression. Below that, the prepare cost (one tree walk + closure tree allocation) outweighs the per-call savings. Above ~10 invocations, prepare is dramatically ahead.

// Filter use case (clear win for prepare):
const isAffordable = engine.prepare(['<', ['get', 'price'], 50]);
products.filter((p) => isAffordable({ $root: p }));

// Authorization check evaluated per request:
const isAdmin = engine.prepare(['in', [['admin', 'owner']], ['get', 'role']]);
app.use((req, res, next) => (isAdmin({ $root: req.user }) ? next() : res.sendStatus(403)));

Operators

defaultOperators (from @laioutr/expression/operators/default) bundles input, logical, comparison, branching, and container. The remaining sets (arithmetic, string, array, object, type) ship as separate subpaths and are not part of the default preset.

Aliases are listed in parentheses.

| Operator | Operands | Description | | ------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | get ($) | path, default? | Read from the current scope by dot/bracket path. The current scope is ctx.$root, which iteration operators (e.g. map) rebind to each element while iterating. Empty string returns the current scope; user.name walks properties; items[0].id resolves numeric brackets to dot segments. A leading ^ walks one scope up per character (^foo reads foo on the parent, ^^foo two levels up); a bare ^ returns the parent scope itself. A leading / anchors to the outermost root regardless of nesting depth (/site always reads site on the original input). ^ and / cannot be combined. Throws NotFoundError if the path does not exist and no default is provided. The default is itself an expression, evaluated only on miss. | | get? ($?) | path | Like get, but returns true if the path exists, false otherwise. Supports the same ^ and / prefixes. | | ?get (?$) | path | Like get, but returns undefined on miss instead of throwing. Mirrors JS optional chaining (?.). Supports the same ^ and / prefixes. Compose with ?? for chained fallbacks: ['??', ['?get', 'a'], ['?get', 'b'], default]. |

| Operator | Operands | Description | | ----------------- | ------------ | -------------------------------------------------------------------------------------------------------------------- | | and (&&) | a, ...rest | Same as JS && — first falsy operand, or the last if all are truthy. | | or (\|\|) | a, ...rest | Same as JS \|\| — first truthy operand, or the last if all are falsy. | | not (!) | value | Logical negation. | | coalesce (??) | a, ...rest | Same as JS ?? — first non-nullish operand, or the last if all are nullish. 0, false, and '' are non-nullish. |

| Operator | Operands | Description | | ----------- | -------- | -------------------------------------------------------------------------- | | eq (==) | a, b | a == b. Deep equality. [1, 2] equals [1, 2]; {a:1} equals {a:1}. | | ne (!=) | a, b | a != b. Deep inequality. | | gt (>) | a, b | a > b. Greater than. | | ge (>=) | a, b | a >= b. Greater than or equal. | | lt (<) | a, b | a < b. Less than. | | le (<=) | a, b | a <= b. Less than or equal. |

| Operator | Operands | Description | | ---------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | | if (?) | cond, then, else | If/then/else. The unchosen branch is not evaluated. | | cond | p1, v1, ..., pN, vN, default | Multi-way branch. Returns the value paired with the first truthy predicate, otherwise the trailing default. Untaken branches are lazy. |

cond takes alternating predicate / value pairs followed by a trailing default. The first truthy predicate wins; its paired value is evaluated and returned. If no predicate matches, the default is evaluated and returned. Predicates and values for unmatched pairs are never evaluated.

engine.eval(['cond', ['>=', ['get', 'score'], 90], 'A', ['>=', ['get', 'score'], 80], 'B', ['>=', ['get', 'score'], 70], 'C', 'F'], {
  $root: { score: 84 },
});
// → 'B'

| Operator | Operands | Description | | --------------- | ------------------ | ------------------------------------------------------------------------------------- | | len | value | Length of a string or array; key count of an object; 0 for null/other. | | member ([]) | container, index | Index into a string, array, or object. Throws NotFoundError for null/non-container. |

Operands are coerced to numbers ('5' becomes 5).

| Operator | Operands | Description | | ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------ | | add (+) | a, b, ...rest | Sum of all operands. | | subtract (-) | a, b, ...rest | a minus each of the remaining operands in order. | | multiply (*) | a, b, ...rest | Product of all operands. | | divide (/) | a, b, ...rest | a divided successively by each of the remaining operands. Throws DivisionByZeroError on a 0 divisor. | | mod (%) | a, b, ...rest | a mod each of the remaining operands, applied successively. Throws DivisionByZeroError on a 0 divisor. | | min | a, b, ...rest | Smallest operand. | | max | a, b, ...rest | Largest operand. |

| Operator | Operands | Description | | ----------- | --------------- | ------------------------------------------------------ | | cat (.) | a, b, ...rest | String concatenation. Operands are coerced to strings. | | contains | outer, inner | true if outer contains inner. | | starts | outer, inner | true if outer starts with inner. | | ends | outer, inner | true if outer ends with inner. |

| Operator | Operands | Description | | -------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | in | haystack, needle | true if haystack contains needle (deep equality). Throws OperandTypeError if haystack is not an array. | | slice | array, start, end? | array.slice(start, end). Throws OperandTypeError if array is not an array. | | join | array, separator | array.join(separator). Throws OperandTypeError if array is not an array. | | arr-of | a, ...rest | Constructs an array from its operands. Each operand is evaluated. ['arr-of', 1, 2, 3][1, 2, 3]. | | map | array, body | Evaluates body once per element of array, with body's $root rebound to the current element. See Body operators and scope rebinding below. |

Body operators and scope rebinding

Iteration operators (currently just map) take an array and a body expression. The body is evaluated once per element with ctx.$root rebound to the current element, so existing path expressions read from the current item without changes:

engine.eval(['map', ['get', 'items'], ['get', 'price']], {
  $root: { items: [{ price: 10 }, { price: 20 }] },
});
// [10, 20]

Outer scopes remain reachable via ^-prefixed paths in get (['get', '^tax']). The original input is reachable via ['get', '/foo']. These operators nest. Throws OperandTypeError if the first operand is not an array.

| Operator | Operands | Description | | -------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | keys | object | Object.keys of the operand. | | values | object | Object.values of the operand. | | obj-of | key, value, ...rest | Constructs an object from alternating key, value operands. Both sides are evaluated; keys are coerced to string. ['obj-of', 'name', 'Alice', 'age', 30]{ name: 'Alice', age: 30 }. Throws ArityError on an odd operand count. |

| Operator | Operands | Description | | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- | | type | value | Returns one of 'string', 'number', 'boolean', 'object', 'array', 'null', 'binary' (for Uint8Array), or 'undefined'. | | bool | value | Coerces to boolean. | | num | value | Coerces to number; NaN becomes 0. | | str | value | Coerces to string. Objects and arrays go through JSON.stringify; primitives are converted to their string form. |

Errors

All evaluation errors extend ExpressionError. Errors thrown from operator code that are not already ExpressionError are wrapped in EvaluationError with the original error attached as cause.

| Error | When | | --------------------- | ---------------------------------------------------------- | | ExpressionError | Base class for all errors below. | | UnknownOpError | The operator name is not registered. | | ArityError | Operand count does not match the operator's arity. | | NotFoundError | get / member path resolution failed. | | OperandTypeError | Operand does not match the type the operator expected. | | DivisionByZeroError | divide / mod operand evaluated to 0. | | EvaluationError | Wraps any non-ExpressionError thrown from operator code. |

import { ExpressionError } from '@laioutr/expression';

try {
  engine.eval(['get', 'missing']);
} catch (e) {
  if (e instanceof ExpressionError) {
    // handle expression-level failure
  }
}

Custom operators

defineOperator is an identity helper that preserves the operator's literal types (name, arity, alias strings) at the call site, so the engine type tracks exactly which operators were registered. A plain object literal compiles, but its types widen (name becomes string, arity becomes OperatorArity), and the engine loses all compile-time name and arity checking. Always declare custom operators with defineOperator.

import { JsonExpression, defineOperator } from '@laioutr/expression';

const opAdd = defineOperator({
  name: '+',
  arity: { min: 2 },
  fn: (args, evalE) => args.reduce((sum, a) => sum + Number(evalE(a)), 0),
});

const engine = new JsonExpression([opAdd]);
engine.eval(['+', 1, 2, 3]); // 6

OperatorDefinition:

| Field | Type | Description | | --------- | ----------------------------------- | ------------------------------------------------------------------------------ | | name | string | Primary operator name. | | aliases | string[]? | Additional names that resolve to the same operator. | | arity | number \| { min } \| { min, max } | Expected operand count. Enforced before fn runs. | | fn | (args, evalExpr, ctx) => any | Implementation. Operands arrive unevaluated; call evalExpr(arg) to evaluate. |

Picking your operator sets

The package entry point ships only the engine class, types, and errors. Operator sets live behind subpath imports, so you pay only for the sets you use.

// Smallest: just the class. No operators bundled.
import { JsonExpression } from '@laioutr/expression';

// Default preset (input, logical, comparison, branching, container).
import { defaultOperators } from '@laioutr/expression/operators/default';

// Default + extra sets.
import { arithmeticOperators } from '@laioutr/expression/operators/arithmetic';

const engine = new JsonExpression([...defaultOperators, ...arithmeticOperators]);

Available subpaths: /operators/default, /operators/input, /operators/logical, /operators/comparison, /operators/branching, /operators/container, /operators/arithmetic, /operators/string, /operators/array, /operators/object, /operators/type.

Individual operators are also exported by name (op + PascalCase, e.g. opCond, opAdd, opGetMaybe, opGetOptional) from each subpath, so you can pick exactly what you need:

import { JsonExpression } from '@laioutr/expression';
import { opGet } from '@laioutr/expression/operators/input';
import { opEq, opGt } from '@laioutr/expression/operators/comparison';
import { opIf } from '@laioutr/expression/operators/branching';

const engine = new JsonExpression([opGet, opEq, opGt, opIf]);

The op prefix avoids collisions with JS reserved words (if, in) and common identifiers (type, str, min, not, slice, keys).

Type safety

The engine is parameterised over the operator set you pass to the constructor. That set drives the types of eval, prepare, and the helper ExpressionOf<typeof engine>.

Adaptive validation

eval and prepare validate at the strictest level the input's static type allows. Same call, two modes:

const engine = new JsonExpression([...defaultOperators, ...arithmeticOperators]);

// Literal input. Name AND arity validated at compile, recursively.
engine.eval(['if', true, 1, 2]);
engine.eval(['if', true, 1]); // ✗ too few args
engine.eval(['if', true, 1, 2, 3]); // ✗ too many args
engine.eval(['if', ['eq', 1], 'a', 'b']); // ✗ nested arity violation
engine.eval(['typo', 1, 2]); // ✗ unknown operator name

// Wide input (ExpressionOf<...>): name validated at the assignment, arity at runtime.
const stored: ExpressionOf<typeof engine> = ['eq', 1, 1];
engine.eval(stored); // ✓ runtime arity check still fires if the stored shape is broken

When validation fails, the TypeScript diagnostic includes a human-readable reason inline in the parameter type. For example, engine.eval(['typo', 1, 2]) reports:

Argument of type '...' is not assignable to parameter of type
'Invalid<"Unknown operator: 'typo'">'.

Reasons cover the three error shapes:

  • Invalid<"Unknown operator: 'foo'">: operator name not registered on this engine.
  • Invalid<"Operator 'if' arity mismatch: got 4, expected 3">: argument count doesn't match the operator's arity.
  • The same reason is propagated up from a nested operand. A typo deep in the tree surfaces at the top-level call.

The strict path triggers automatically for inline array literals and as const variables (anything where TypeScript sees the literal tuple shape). A variable typed as ExpressionOf<typeof engine> falls back to runtime arity checking; useful for stored predicates where the shape isn't known statically.

Typed expressions: Expression and ExpressionOf<E>

ExpressionOf<typeof engine> is the type of "an expression that this engine can evaluate". Use it for stored predicates, JSON-deserialized input, or anywhere you need a typed slot for "some expression this engine knows about". It resolves to Expression<Ops> where Ops is the engine's operator set, catches typos in operator names, and is loose on arity.

import { JsonExpression, type ExpressionOf } from '@laioutr/expression';
import { defaultOperators } from '@laioutr/expression/operators/default';

const engine = new JsonExpression(defaultOperators);

const filter: ExpressionOf<typeof engine> = ['and', ['==', ['get', 'inStock'], true], ['<', ['get', 'price'], 50]];

Expression (no type parameter) accepts any string as the operator name. Use it when the operator set isn't known at the type level (e.g. when validating untrusted input before passing it to a specific engine).

import type { Expression } from '@laioutr/expression';

function isValid(maybeExpr: Expression): boolean {
  /* ... */
}

A loose Expression value passed to engine.eval compiles, but the engine no longer enforces at the type level that the operator name is registered; the runtime check (throwing UnknownOpError on miss) is what catches a mismatch. Narrow to ExpressionOf<typeof engine> at the boundary where you know which engine will run it if you want compile-time name validation.

Limits

ValidateCall recurses through nested calls and is bounded by TypeScript's instantiation-depth check. Concretely:

  • Nesting depth: ~31 levels of straight nesting before TS2589 fires.
  • Variadic arity: ~999 arguments to a single operator before TS2589 fires.
  • Branching width is essentially unlimited (depth, not breadth, is the limiting factor).

Real-world expression trees never approach these limits.

For type-level validation outside of an eval / prepare call, the package exports OpMap, ValidateCall, and Invalid. See the JSDoc on each export for usage.

Benchmarks

Run with pnpm bench (uses vitest bench). Numbers below are throughput (operations per second) on Apple Silicon (Darwin arm64) with Node 22.22, captured from a single bench run; expect ±5% run-to-run variance. Bench source: src/__bench__/benchmark.bench.ts.

| Expression shape | eval | prepared | json-logic-js | json-joy interpreter | json-joy compiled (JIT) | | ------------------------------------------------- | -----: | ---------: | --------------: | -------------------: | ----------------------: | | Simple equality (['==', ['get', 'foo'], 'bar']) | 9.68M | 14.01M | 4.93M | 3.11M | 15.79M | | CloudEvents predicate (4 paths, mixed ops) | 2.86M | 4.54M | 1.43M | 0.75M | 9.12M | | Arithmetic chain (+, -, *, / + get) | 3.37M | 5.11M | 1.54M | 1.13M | 14.15M | | Deep and nesting (10 levels) | 1.49M | 2.77M | 1.22M | 0.51M | 10.53M | | Large in lookup (100-element list) | 6.00M | 7.68M | 0.92M | 1.08M | 1.56M |

All numbers in million operations per second. prepared is engine.prepare(expr) invoked repeatedly, amortizing the one-time prepare cost away. Compared against @jsonjoy.com/json-expression (the reference implementation, both interpreter and JIT-compiled forms) and json-logic-js (the most popular alternative).

One-shot vs. amortized

The third interesting number is prepare + run: building a fresh prepared expression and invoking it once, the worst case for the prepared form. Below are the same shapes measuring prepare + run:

| Expression shape | eval | prepare + run | Crossover | | --------------------- | -----: | --------------: | --------: | | Simple equality | 9.68M | 6.52M | ~3 calls | | CloudEvents predicate | 2.86M | 1.74M | ~3 calls | | Arithmetic chain | 3.37M | 2.19M | ~3 calls | | Deep and nesting | 1.49M | 1.06M | ~2 calls | | Large in lookup | 6.00M | 4.57M | ~3 calls |

For one-shot evaluation, eval is ~1.3-1.6× faster than prepare + run because the prepare cost is roughly equivalent to one extra eval. The crossover is at ~2-3 invocations of the same expression, after which prepare wins decisively.

Takeaways:

  • prepared beats json-logic-js and the json-joy interpreter on every shape, by 2-9×. The path segment cache in get and the primitive-needle fast path in in carry most of the win.
  • prepare is 1.4-1.9× faster than eval on the same shape. For repeated evaluation, prefer it.
  • in against a long primitive list beats even the JIT-compiled form. The native Array.prototype.includes intrinsic outpaces json-joy's generated isInArr loop.
  • The JIT-compiled form is 1.1-3.8× faster than prepared on every other shape. For high-throughput, hot-path predicates evaluated millions of times against the same expression, the reference implementation's JIT is the right tool. This package targets the case where stored expressions are evaluated occasionally against varied inputs and bundle size / simplicity matter more than peak throughput.

See also

This package is a subset of @jsonjoy.com/json-expression focused on bundle size and simplicity.

Differences from the reference implementation

  • get path syntax. This package uses dot/bracket paths (user.name, items[0].id, '' for the root). The reference implementation uses JSON Pointer (/user/name, /items/0/id, '' for the root). We additionally support ^ and / prefixes for walking up the scope stack and anchoring to the outermost root, used by map to expose enclosing scopes.
  • map body binding. The body of map runs with ctx.$root rebound to the current element, so existing get-based path expressions work unchanged inside the body. The reference implementation binds the current element to a named variable (['map', arr, 'item', body]) and the body reads it back via ['$', 'item/...']. We chose rebind-$root plus scope-prefix get so a body authored against a top-level expression keeps working when wrapped in a map.
  • No singleton engine. Consumers construct a JsonExpression and choose which operator sets to register. The reference implementation ships a pre-configured engine.
  • Subpath-only operator imports. Operators ship behind individual subpaths (/operators/<set>) for predictable bundle floor on every bundler. The reference implementation uses a single root with tree-shaking.
  • Interpreter only. Evaluation walks the expression tree at runtime. The json-joy package also ships a JIT compiler that emits JavaScript for higher-throughput evaluation.

Operators that share a name across both packages have matching semantics, except for get and map.

License

MIT