tiny-rule.js
v0.0.3
Published
TypeScript rule engine with AST-based expressions, plugin system, and observability
Downloads
329
Maintainers
Readme
tiny-rule.js
TypeScript rule engine with AST-based expressions, a declarative plugin API, and built-in observability.
Features
- AST-based expressions — compose rules from typed literal, path, logical, and callable expression nodes
- Plugin system — extend the engine with custom operators via
PluginBuilder - Fluent rule builder — chainable
RuleBuilderAPI for defining rules - Portability — import/export rule sets as JSON
- Observability — emit lifecycle events (
build:start,rule:end,rule:error, etc.) - Explanation — each compiled rule carries a human-readable explanation of its condition
- Validation — built-in expression validator catches errors at build time
- Fast — compiled closures avoid AST traversal at evaluation time
- Optimizer — automatic constant folding and expression simplification during build
Installation
pnpm add tiny-rule.jsnpm install tiny-rule.jsyarn add tiny-rule.jsQuick Start
import { RuleEngine } from "tiny-rule.js";
import { bool, and, num, path, str } from "tiny-rule.js/ast";
import { ComparisonPlugin, eq, gte, gt } from "tiny-rule.js/plugins/comparison";
const engine = new RuleEngine()
.plugins(ComparisonPlugin)
.rule((r) =>
r
.id("premium-user")
.when(
and(
gte(path("age"), num(18)),
eq(path("country"), str("PY")),
gt(path("score"), num(700)),
eq(path("active"), bool(true)),
),
)
.then("premium"),
)
.build();
const result = engine.evaluateOne("premium-user", {
age: 21,
country: "PY",
score: 800,
active: true,
});
console.log(result.matched); // true
console.log(result.explanation); // "age >= 18 && country == PY && score > 700 && active == true"Guide
Expressions (AST)
tiny-rule.js uses a typed abstract syntax tree to represent conditions. Every expression node carries an __output type ("boolean", "number", "string", "date", or "unknown") enabling compile-time type checks.
Literal helpers — import from tiny-rule.js/ast:
| Helper | Output | Description |
| --------- | ----------- | --------------- |
| num(n) | "number" | Numeric literal |
| str(s) | "string" | String literal |
| bool(b) | "boolean" | Boolean literal |
| date(d) | "date" | Date literal |
Logical helpers — import from tiny-rule.js/ast:
| Helper | Output | Description |
| ----------------- | ----------- | ---------------------------- |
| and(...exprs) | "boolean" | Logical AND |
| or(...exprs) | "boolean" | Logical OR |
| not(expr) | "boolean" | Logical NOT |
| path(p) | unknown | Context path resolver |
| regexp(exp, op) | "boolean" | RegExp test against a string |
Example:
import { and, or, not, path, num, str, bool, regexp } from "tiny-rule.js/ast";
import { gte, eq } from "tiny-rule.js/plugins/comparison";
const expr = and(
gte(path("age"), num(18)),
or(eq(path("role"), str("admin")), eq(path("role"), str("moderator"))),
not(bool(false)),
);Rules
Define rules with the fluent RuleBuilder API:
import { RuleBuilder } from "tiny-rule.js/rule";
import { path, num } from "tiny-rule.js/ast";
import { gte } from "tiny-rule.js/plugins/comparison";
const rule = new RuleBuilder()
.id("is-adult")
.name("Is Adult")
.when(gte(path("user.age"), num(18)))
.then("allow-access")
.else("deny-access")
.build();Or inside the engine via the .rule() method:
engine.rule((r) =>
r
.id("is-adult")
.when(gte(path("user.age"), num(18)))
.then("allow-access"),
);Rule interface:
| Field | Type | Description |
| ----------- | --------------- | ------------------------------------------ |
| id | string | Unique rule identifier |
| name? | string | Human-readable name |
| condition | AnyExpression | The condition expression (must be boolean) |
| action | string[] | Action labels emitted when matched |
| else | string[] | Action labels emitted when unmatched |
Rule Engine
The RuleEngine class orchestrates the full pipeline: validation, explanation, optimization, compilation, and evaluation.
import { RuleEngine } from "tiny-rule.js";
const engine = new RuleEngine()
.plugins(ComparisonPlugin)
.rule((r) =>
r
.id("r1")
.when(gte(path("age"), num(18)))
.then("adult"),
)
.rule((r) =>
r
.id("r2")
.when(gte(path("score"), num(200)))
.then("high-score"),
)
.build();
// Evaluate all rules
const allResults = engine.evaluate({ age: 25, score: 500 });
// Evaluate a single rule by ID
const singleResult = engine.evaluateOne("r1", { age: 25, score: 500 });Returned IRuleExecution:
| Field | Type | Description |
| ------------- | ---------- | ------------------------------------ |
| ruleId | string | Matched rule ID |
| matched | boolean | Whether the condition was satisfied |
| explanation | string | Human-readable condition explanation |
| action | string[] | Action labels from .then() |
| else | string[] | Action labels from .else() |
Portability (Import / Export)
Export rules to JSON for storage or transport:
import { JsonRuleSerializer } from "tiny-rule.js/serializer";
const json = engine.export(new JsonRuleSerializer(), { pretty: true });Import rules from JSON:
import { JsonRuleSerializer } from "tiny-rule.js/serializer";
const engine = new RuleEngine()
.plugins(ComparisonPlugin)
.import(new JsonRuleSerializer(), json)
.build();The serializer system is generic — any IRuleSerializer<T, O> implementation can be used:
interface IRuleSerializer<T, O = unknown> {
serialize(payload: RuleSet, options?: O): T;
deserialize(payload: T): RuleSet;
}Observability (Events)
The engine emits lifecycle events. Subscribe via .on() and get an unsubscribe function:
const unsubscribe = engine.on("build:start", ({ rule }) => {
console.log(`Building rule: ${rule.id}`);
});
engine.on("rule:end", ({ rule, result }) => {
console.log(`${rule.id} matched: ${result.matched}`);
});
engine.on("build:error", ({ err }) => {
console.error("Build failed:", err);
});Available events:
| Event | Payload |
| ------------- | ------------------------------------------------------------- |
| build:start | { rule: IRule } |
| build:end | { rule: ICompiledRule } |
| build:error | { err: unknown } |
| rule:start | { rule: ICompiledRule; ctx?: TCtx } |
| rule:end | { rule: ICompiledRule; result: IRuleExecution; ctx?: TCtx } |
| rule:error | { rule: ICompiledRule; err: unknown; ctx?: TCtx } |
Optimizer
The engine automatically optimizes expressions during the build phase using constant folding and simplification:
- Boolean folding —
and(true, expr)→expr,and(false, expr)→false,or(true, expr)→true,or(false, expr)→expr - Unary simplification —
and(expr)→expr(single operand unwrapped) - Empty reduction —
and()→true,or()→false - Plugin-level folding — plugins can fold constant sub-expressions (e.g.,
plus(num(10), num(20))→num(30))
Optimization runs automatically on build() — no manual invocation needed.
Plugins
Plugins extend the engine with custom callable expression operators. The system has two layers:
IPlugin— a bundle that registers one or more expression plugins into theExpressionRegistryIExprPlugin— a single operator (e.g.>=,len,startsWith) withcompile,explain, and optionalverifyfunctions
Creating a Plugin
Use PluginBuilder to define a custom operator:
import { PluginBuilder, ExpressionRegistry } from "tiny-rule.js/registry";
import { IPlugin } from "tiny-rule.js/plugins";
import { str, num } from "tiny-rule.js/ast";
import { asString, asNumber } from "tiny-rule.js/pipeline";
// 1. Build the expression plugin
const { plugin: lenP, helper: len } = new PluginBuilder<number>()
.operator("len") // operator name used in expressions
.output("number") // output type
.explain((value) => `${value}.length`) // human-readable explanation
.compile((value) => (ctx) => asString(value(ctx)).length)
.build();
// 2. Create the plugin bundle
const StringPlugin: IPlugin = {
load(registry: ExpressionRegistry) {
registry.add(lenP);
},
};Using the Plugin
import { RuleEngine } from "tiny-rule.js";
import { str } from "tiny-rule.js/ast";
import { eq } from "tiny-rule.js/plugins/comparison";
const engine = new RuleEngine()
.plugins(StringPlugin)
.rule((r) =>
r
.id("short-name")
.when(eq(len(str("alice")), num(5)))
.then("valid"),
)
.build();PluginBuilder API
| Method | Description |
| ------------------------------------- | ---------------------------------------------- |
| .operator(name) | Sets the operator string (e.g. "startsWith") |
| .output(type) | Sets the output ExpressionType |
| .explain((...args) => string) | Generates human-readable explanation |
| .compile((...closures) => Closure) | Produces a compiled closure from sub-closures |
| .optimize((expr) => { expression }) | Optional constant folding / simplification |
| .verify((...exprs) => { errors }) | Optional validation logic |
| .build() | Returns { plugin, helper } |
The helper function creates an AST ICallExpr node that references the operator:
const expr = len(str("hello"));
// => { kind: "call", operator: "len", operands: [...], __output: "number" }Built-in ComparisonPlugin
The ComparisonPlugin registers six comparison operators:
| Helper | Operator | Description |
| ------ | -------- | ---------------- |
| gte | >= | Greater or equal |
| gt | > | Greater than |
| lte | <= | Less or equal |
| lt | < | Less than |
| eq | == | Equal |
| neq | != | Not equal |
Built-in MathPlugin
The MathPlugin registers six arithmetic operators:
| Helper | Operator | Description |
| ------ | -------- | -------------- |
| plus | + | Addition |
| sub | - | Subtraction |
| mul | * | Multiplication |
| div | / | Division |
| mod | % | Modulus |
| pow | ^ | Exponentiation |
API Reference
tiny-rule.js
export * from "./ast";
export * from "./core";
export * from "./plugins";
export * from "./plugins/comparison";
export * from "./registry";
export * from "./pipeline";
export * from "./monitor";tiny-rule.js/ast
- Types:
ExpressionType,IExpression<T>,INumLiteralExpr,IStrLiteralExpr,IBoolLiteralExpr,IDateLiteralExpr,IPathExpr<T>,IRegExpExpr,IAndExpr,IOrExpr,INotExpr,ICallExpr<T>,BooleanExpression,AnyExpression<T> - Helpers:
num(),str(),bool(),date(),and(),or(),not(),path(),regexp()
tiny-rule.js/core
RuleEngine<TCtx>— main engine class with.plugins(),.rule(),.build(),.evaluate(),.evaluateOne(),.import(),.export(),.on()IRuleExecution— evaluation resultRuleEngineEventsMap<TCtx>— event type map
tiny-rule.js/pipeline
Compiler— compilesAnyExpressiontree into executableClosureExplanator— generates human-readable explanation stringsValidator— validates expression trees at build timeOptimizer— constant folding and expression simplificationClosure—(ctx?: unknown) => TResultasString(),asNumber(),asBoolean(),asDate(),asComparable()
tiny-rule.js/rule
RuleBuilder— fluent rule builder:.id(),.name(),.when(),.then(),.else(),.build()IRule— rule descriptorICompiledRule— compiled rule withClosurecondition
tiny-rule.js/registry
ExpressionRegistry— registry of callable expression pluginsPluginBuilder<T>— builder for creating expression plugins and their helper functions
tiny-rule.js/plugins
IPlugin—{ load(registry: ExpressionRegistry): void }
tiny-rule.js/plugins/comparison
ComparisonPlugin— registersgte,gt,lte,lt,eq,neqgte,gt,lte,lt,eq,neq— helper functions for expression construction
tiny-rule.js/plugins/math
MathPlugin— registersplus,sub,mul,div,mod,powplus,sub,mul,div,mod,pow— helper functions for expression construction
tiny-rule.js/serializer
JsonRuleSerializer— built-in JSON serializer with optional pretty-printingIRuleSerializer<T, O>— generic serializer interfaceRuleSet—{ rules: IRule[] }serializable rule collection type
tiny-rule.js/monitor
TBus<T>— typed event bus with.on()and.emit()
Benchmarks
Benchmarks compare tiny-rule.js against json-rules-engine (v7) using identical rule logic on randomized contexts. They cover single-rule evaluation, multi-rule evaluation (10 to 10,000 rules), and build/compilation time.
Running Benchmarks
pnpm benchThis executes all benchmark suites via vitest bench:
| Suite | File | What it measures |
| --------------------- | ----------------------------- | ------------------------------------- |
| Single-rule execution | benchmark/exec.bench.ts | evaluateOne() vs jsonEngine.run() |
| Multi-rule execution | benchmark/multiple.bench.ts | evaluate() with 10–10,000 rules |
| Build time | benchmark/build.bench.ts | Compilation of 1–1,000 rules |
License
MIT
