sdxc

Type to search, or start from one of these:

[ Language & values ]

@sdxc/expression

Boolean conditions over a context, stored as typed JSON or written as text, with operators you add

npm add @sdxc/expression
pnpm add @sdxc/expression
yarn add @sdxc/expression
bun add @sdxc/expression
Depends on
remix

Boolean conditions over a context, stored as typed JSON or written as text, with operators you add.

Installation

npm add @sdxc/expression

compile() reports failures as a Result from @sdxc/result, and a dialect's schema is built with remix/data-schema; both install alongside this package.

Usage

Evaluating a stored condition

import { createLanguage } from "@sdxc/expression";
import { isSuccess } from "@sdxc/result";

let conditions = createLanguage();

let compiled = conditions.compile({
	op: "all",
	of: [
		{ op: "eq", field: "plan.tier", value: "pro" },
		{ op: "in", field: "country", values: ["AR", "UY"] },
	],
});

if (isSuccess(compiled)) {
	conditions.evaluate(compiled.data, { plan: { tier: "pro" }, country: "AR" }); // true
	conditions.evaluate(compiled.data, { plan: { tier: "pro" } }); // false — no country
}

Compile once, when the condition is loaded, and evaluate as often as you like: evaluation is synchronous and pure.

Writing a condition as text

let conditions = createLanguage({ reference: "segment" });

let parsed = conditions.parse(
	`plan.tier == "pro" and (country in ["AR", "UY"] or segment("internal"))`,
);
// Success: the same JSON form compile() takes

conditions.parse(`plan.tier == "pro" and`);
// Failure: ExpressionError { line: 1, column: 23, message: "Expected a condition after 'and'" }

conditions.stringify({ op: "not", of: { op: "exists", field: "beta" } }); // "not exists(beta)"

The JSON form stays the one to store; the text form is for people typing a rule into a form, a config file or an environment variable.

Sharing conditions by name

let conditions = createLanguage({ reference: "segment" });

let segments = {
	internal: { op: "endsWith", field: "email", value: "@example.com" },
	beta: {
		op: "any",
		of: [
			{ op: "segment", name: "internal" },
			{ op: "eq", field: "beta", value: true },
		],
	},
};

let compiled = conditions.compile({ op: "segment", name: "beta" }, { references: segments });

A reference resolves once per references object, so many conditions compiled against the same object share one compiled tree. A name nobody declared, or a cycle, fails the compile.

Adding an operator

import { createLanguage, defineOperator } from "@sdxc/expression";
import { failure, success } from "@sdxc/result";
import * as s from "remix/data-schema";

let olderThan = defineOperator({
	op: "olderThan",
	args: ["field", "days"],
	schema: s.object({ days: s.number() }),
	compile: (node) =>
		node.days >= 0 ? success(node.days * 86_400_000) : failure(new Error("Expected days ≥ 0")),
	test: (value, _node, ms) => value instanceof Date && Date.now() - value.getTime() > ms,
});

let accounts = createLanguage({ operators: [olderThan] });

accounts.compile({ op: "olderThan", field: "createdAt", days: 30 });

The language reads the field before test runs and answers false when it is missing, so an operator only ever sees a value that is there.

Leaving operators out

let rules = createLanguage({ builtins: ["all", "any", "not", "contains"] });

rules.compile({ op: "matches", field: "title", pattern: "(a+)+$" });
// failure: ExpressionError { path: "op", message: '"matches" is not an operator of this language' }

A dialect evaluating conditions written by untrusted users can leave out matches and keep evaluation linear in the size of the context.

API

createLanguage(options?)

Defines a dialect and returns a Language. Every option is optional:

  • builtins: the built-in operators kept, all of them by default.

  • reference: the operator name a reference is written under, as in { op: "segment", name: "internal" }. Without it the dialect has no references.

  • operators: field operators made with defineOperator. One named like a built-in replaces it.

Language

  • schema: a Standard Schema for the dialect's JSON form, to validate an expression before storing it.

  • compile(expression, { references }?): validates an expression, runs every operator's compile step and resolves references. Returns Result<Compiled, ExpressionError>.

  • evaluate(compiled, context): whether a compiled expression holds for a context. The context is a JSON object, with Date values allowed.

  • parse(text): reads the text form into the JSON form, validated against the schema. Returns Result<Expression, ExpressionError>, the error carrying the line and column the text broke at.

  • stringify(expression): prints the canonical text, which parse reads back to the same JSON.

  • Expression and Compiled: type-only members; write typeof language.Expression for the JSON form's type and typeof language.Compiled for the compiled one.

Built-in operators

OperatorJSON formHolds when the field…
eq, ne{ op, field, value } (a JSON primitive)is, or is not, exactly value
in, notIn{ op, field, values }is, or is not, one of values
lt, lte, gt, gte{ op, field, value } (a number)is a number ordered against value
startsWith, endsWith, contains{ op, field, value } (a string)is a string with value in place
matches{ op, field, pattern }is a string the pattern matches
exists{ op, field }resolves to anything, null too
all, any{ op, of: [...] }—
not{ op, of }—
always{ op }—

field is a dotted path: plan.tier reads a nested object and roles.0 an array element. A path that resolves to nothing makes every operator except exists false. Every comparison stays within one type, so eq between "5" and 5 is false and lt on a string is false. matches compiles its pattern with the v flag at compile time, so a bad pattern fails the compile.

Text form

TextJSON form
a and b, a or b, not aall, any, not; not binds over and over or
(a or b)grouping, kept as its own node
truealways
field == v, !=, <, <=, >, >=eq, ne, lt, lte, gt, gte
field in [...], field not in [...]in, notIn
op(field, ...args)any other operator, arguments in the order of its args
segment("internal")a reference, under the dialect's spelling
all(...), any(...)a chain of fewer than two members

Values are JSON literals, written exactly as they are stored. A field is a bare dotted path such as plan.tier or roles.0; one a bare path cannot spell, such as user-agent or a keyword like in, is quoted in backticks: `user-agent` == "bot".

defineOperator(definition)

Declares a field operator. op is its name, args lists its fields in call order starting with field, schema validates the fields beyond op and field, and test(value, node, prepared) answers for a value that is there. The optional compile(node) returns a Result; what it prepares is kept on the compiled node as prepared and passed to test.

ExpressionError

The failure every step reports. path names the failing node in the JSON form, like of.1.pattern, and is empty for the root. A parse failure also sets line and column, both 1-based.

read(context, path)

Reads a dotted path out of a context exactly as an expression does, returning undefined for a miss and keeping a Date whole. Use it when code beside an expression reads the same fields.

Pattern: A sampling exemption from an environment variable

import { createLanguage } from "@sdxc/expression";
import { isSuccess } from "@sdxc/result";

let conditions = createLanguage();

// KEEP_WHEN = `kind == "job" or status >= 500 or exists(error)`
let parsed = conditions.parse(process.env.KEEP_WHEN ?? "true");
let compiled = isSuccess(parsed) ? conditions.compile(parsed.data) : parsed;

function keep(fields: Record<string, string | number | boolean | null>) {
	return isSuccess(compiled) && conditions.evaluate(compiled.data, fields);
}

Pattern: Validating a rule before storing it

import { createLanguage } from "@sdxc/expression";
import { isFailure } from "@sdxc/result";

let conditions = createLanguage({ reference: "segment" });

async function saveRule(
	store: Map<string, unknown>,
	segments: Record<string, unknown>,
	input: unknown,
) {
	let compiled = conditions.compile(input, { references: segments });
	if (isFailure(compiled)) {
		return { error: compiled.error.message, at: compiled.error.path };
	}

	store.set("rule", input);
	return { ok: true };
}

Compiling before writing catches what a schema alone cannot: an unknown reference, a reference cycle, or a pattern that does not compile.

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.