A small, deterministic expression language: parse into a typed AST with spans, then evaluate safely against an explicit environment.
- Docs: https://jsr.io/@claudiu-ceia/exp/doc
- Package: https://jsr.io/@claudiu-ceia/exp
- Repo: https://github.com/ClaudiuCeia/exp
import { evaluateExpression } from "jsr:@claudiu-ceia/exp";
const res = evaluateExpression('status == "open" && priority >= 3', {
env: { status: "open", priority: 4 },
throwOnError: false,
});
if (res.success) {
console.log(res.value); // true
}exp is a tiny, deterministic expression parser + evaluator intended for "mini-language" use cases:
- API-rich filters (
status == "open" && priority >= 3) - data-wrangling pipelines (
input |> map(...) |> filter(...)) - backtesting strategies (signals, conditions, thresholds) in a constrained DSL
Design goals:
- No
eval/new Function— all evaluation is interpreter-based. - Typed AST + spans — nodes carry
{ start, end }indices for diagnostics. - Safe-by-default access — expressions only touch data/functions you place
in
env. - Budgeted evaluation — max steps, recursion depth, and array literal size.
Install:
# Deno
deno add jsr:@claudiu-ceia/exp
# Node/Bun (via JSR)
npx jsr add @claudiu-ceia/expEvaluate (non-throwing) and render a pretty diagnostic on failure:
import {
evaluateExpression,
ExpEvalError,
formatDiagnosticReport,
} from "jsr:@claudiu-ceia/exp";
const input = "missing + 1";
try {
const res = evaluateExpression(input); // throws by default
console.log(res.value);
} catch (e) {
if (e instanceof ExpEvalError) {
console.error(
formatDiagnosticReport(input, {
message: e.message,
span: e.span,
index: e.index,
}),
);
} else {
throw e;
}
}Notes:
- Missing identifiers throw by default (
unknownIdentifier: "error"). - Opt into legacy behavior with
unknownIdentifier: "undefined".
- Installation
- Getting started
- Supported syntax
- Safe evaluation model
- API reference
- Errors and diagnostics
- CLI
- Development
- License
The expression language is intentionally small, but ergonomic enough for real application DSLs.
- Full JavaScript parsing.
- Executing untrusted code via
eval/new Function.
Expressions:
- literals: numbers, strings,
true,false,null,undefined - identifiers:
[A-Za-z_]followed by[A-Za-z0-9_]*(withtrue/false/nullreserved) - arrays:
[expr, expr, ...] - grouping:
(expr) - postfix chaining:
expr.identandexpr(arg1, arg2, ...)(chainable) - unary:
!,+,- - binary (with precedence):
* / %,+ -,< <= > >=,== !=,&& || ?? - conditional:
test ? consequent : alternate - pipeline:
lhs |> fnandlhs |> fn(arg1, arg2, ...)(desugars tofn(lhs)/fn(lhs, ...))
std is always available during evaluation (you don’t need to pass it in
env). It exposes a small set of deterministic helpers:
std.len(x)— length of a string or array- math:
std.abs,std.min,std.max,std.clamp,std.floor,std.ceil,std.round,std.trunc,std.sqrt,std.pow - strings:
std.lower,std.upper,std.trim,std.startsWith,std.endsWith,std.includes,std.slice
Note: std.includes(haystack, needle) works for both strings (substring check)
and arrays (membership check).
env.std is reserved and cannot be overridden.
Equality is intentionally JS-like for primitives, but never coerces
objects/arrays/functions via implicit ToPrimitive (so no surprise
toString() / valueOf() calls).
- Primitives: loosely coerced similar to JavaScript
null == undefinedistrue- booleans coerce to numbers (
true→1,false→0) - strings and numbers may coerce via
Number(...)
- Non-primitives (plain objects, arrays, functions): reference equality only
user == usercan betrueuser == "[object Object]"isfalse(no coercion)
String literals:
- single or double quotes
- ECMAScript-oriented escape semantics (see
src/string_literal.tsfor tc39 links) - strict-mode-style failures for digit/octal escapes
Filters:
status == "open" && priority >= 3user.plan != "free" && (user.age >= 18 || user.admin == true)
Chaining:
user.profile.namefn(1, 2).next(3).done
Use evaluateExpression to parse + evaluate in one step, with an explicit
environment and resource budgets.
At a high level:
- Identifiers read from
envonly. - Member access is restricted.
- Calls are only possible through functions present in
env. - Evaluation has configurable budgets.
env is the only way expressions can access data and functions. Identifiers
resolve to properties on env.
- Missing identifiers throw by default (
unknownIdentifier: "error"). - Set
unknownIdentifier: "undefined"to treat missing identifiers asundefined. - Values must be made of supported runtime values:
- primitives:
undefined | null | boolean | number | string - arrays of supported values
- plain objects (
{...}) whose values are supported values - functions that accept/return supported values
- primitives:
Member access (obj.prop) is intentionally conservative:
- Works on plain objects (and arrays only expose
.length). - Blocks
__proto__,prototype, andconstructor.
env is validated at runtime: it must be a plain object (or proto-null object),
and all nested values must be supported runtime values.
- Only plain objects expose own-properties.
- Arrays expose
.lengthonly. - Everything else returns
undefined.
This is designed to avoid prototype leakage and surprise access to inherited properties.
Evaluation supports a few defensive limits (all optional):
maxSteps(default10_000): max AST nodes visitedmaxDepth(default256): max recursion depth while evaluatingmaxArrayElements(default1_000): max elements in an array literal
import { evaluateExpression } from "jsr:@claudiu-ceia/exp";
const env = {
status: "open",
priority: 4,
};
const res = evaluateExpression('status == "open" && priority >= 3', {
env,
throwOnError: false,
});
if (res.success) {
console.log(res.value);
}import { evaluateExpression } from "jsr:@claudiu-ceia/exp";
const env = {
user: { plan: "Free" },
};
const res = evaluateExpression('std.includes(std.lower(user.plan), "free")', {
env,
maxSteps: 5_000,
throwOnError: false,
});import { evaluateExpression } from "jsr:@claudiu-ceia/exp";Or add it to your project:
deno add jsr:@claudiu-ceia/expThis package is published for npm via a generated build.
npx jsr add @claudiu-ceia/expThen:
import { evaluateExpression } from "@claudiu-ceia/exp";import { parseExpression } from "jsr:@claudiu-ceia/exp";
const parsed = parseExpression("1 + 2 * 3", { throwOnError: false });
if (parsed.success) {
console.log(parsed.value.kind); // "binary"
}import { evaluateAst, parseExpression } from "jsr:@claudiu-ceia/exp";
const ast = parseExpression("x + 1").value;
const out = evaluateAst(ast, { env: { x: 41 }, throwOnError: false });Parse a single expression into a typed AST.
- Import:
import { parseExpression } from "jsr:@claudiu-ceia/exp" - Returns:
ParseResult - Throws:
ExpParseError(default behavior)
throwOnError?: boolean— defaulttrue
- Success:
{ success: true, value: Expr } - Failure:
{ success: false, error: ParseError }
message: string— compact parser error messageindex: number— byte index into the input string
Parse + evaluate in one step.
- Import:
import { evaluateExpression } from "jsr:@claudiu-ceia/exp" - Returns:
EvalResult - Throws:
ExpEvalError(default behavior)
Includes all EvalOptions plus:
throwOnParseError?: boolean— defaulttrue
Parse errors:
- If
throwOnParseErroristrue(default), parse errors throwExpParseError. - If
throwOnParseErrorisfalse, parse errors return{ success: false, error: { message, index, steps: 0 } }.
Evaluate a pre-parsed AST.
- Import:
import { evaluateAst } from "jsr:@claudiu-ceia/exp" - Returns:
EvalResult - Throws:
ExpEvalError(default behavior)
env is validated at runtime before evaluation begins.
env?: Record<string, RuntimeValue>— default{}unknownIdentifier?: "error" | "undefined"— default"error"maxSteps?: number— default10_000maxDepth?: number— default256maxArrayElements?: number— default1_000throwOnError?: boolean— defaulttrue
- Success:
{ success: true, value: RuntimeValue } - Failure:
{ success: false, error: EvalError }
message: string— user-facing error messagespan?: Span— present for evaluation errors tied to an AST nodesteps?: number— step counter at time of failureindex?: number— present when failure is due to parse error (only returned whenthrowOnParseError: false)
All AST nodes include span: { start: number; end: number }.
Expr is a tagged union with these kinds:
number,string,boolean,null,undefinedidentifierarrayunarybinarymembercallconditional
RuntimeValue is the allowed runtime data model:
- primitives:
undefined | null | boolean | number | string - arrays of
RuntimeValue - plain objects (
{...}orObject.create(null)) withRuntimeValuevalues - functions:
(...args: RuntimeValue[]) => RuntimeValue
Notes:
envmust be a plain/proto-null object at runtime; class instances (e.g.Date) are rejected.- Function return values are validated; returning an unsupported value fails evaluation.
When you enable throwing (the default), you’ll get typed errors.
- Extends
Error - Fields:
index: number
- Extends
Error - Fields:
span?: Spansteps?: numberindex?: number
This makes it easy to render caret diagnostics from either a byte index or an
AST span.
Example caret formatter:
import { formatCaret } from "jsr:@claudiu-ceia/exp";
console.log(formatCaret("1 + ", 4));This package also exports a richer report-style formatter (used by the CLI):
import { formatDiagnosticReport } from "jsr:@claudiu-ceia/exp";
console.log(
formatDiagnosticReport("1 + ", {
message: "expected expression at 1:4",
index: 4,
}),
);Diagnostics helpers exported from mod.ts:
-
formatCaret/formatSpanCaret -
formatDiagnosticCaret(prefersindex, falls back tospan.start) -
formatDiagnosticReport(Elm/OCaml-inspired report output) -
ExpParseError: includesindex(byte index into the input string) -
ExpEvalError: includesspan(AST span) andsteps(budget counter)
If you prefer non-throwing control flow, use throwOnError: false and inspect
the returned { success: false, error: { message, span?, steps?, index? } }.
deno task checkdeno test
This repo includes a small Deno-only CLI (not part of the npm build), built with
@stricli/core.
deno task repldeno task exp -- run [file]
For real usage, you typically want helper functions in env (so JSON alone is
often not enough). The CLI supports both:
--env path/to/env.ts(JS/TS module; supports functions)--env-json path/to/env.json(JSON object; values only)
Example env.ts:
export const env = {
lower: (s: unknown) => (typeof s === "string" ? s.toLowerCase() : ""),
user: { plan: "Free" },
};Run:
deno task exp -- run --env ./env.ts program.expr
echo '1 + 2*3' | deno task exp -- run
deno task repl -- --env ./env.ts
# value-only env (no functions)
echo 'x + 1' | deno task exp -- run --env-inline '{"x": 41}'
# see full flag docs
deno task exp -- --helpMIT