A small, extensible declarative rule engine written in TypeScript.
You configure rules in a central EngineConfig, the engine builds the rule pipeline automatically, and then your data flows through each rule in sequence.
All rules are defined in a single configuration object:
const config: EngineConfig = {
rules: [
{
type: "synonyms",
config: {
groups: [
{ canonical: "A", variants: ["a1", "a2", "a3"] },
{ canonical: "B", variants: ["b1", "b3"] },
{ canonical: "C", variants: ["c1", "c4"] },
{ canonical: "Z", variants: ["z1"] },
],
},
},
{
type: "precedence",
config: {
precedence: ["A", "B", "C", "D", "E", "F", "Z"],
},
},
],
};Each rule has:
- a type (
synonyms,precedence, …) - a custom config object
Each rule type is associated with a factory that knows how to create its runtime class:
const ruleRegistry: Record<RuleType, RuleFactory<RuleContext>> = {
synonyms: (def) => new SynonymRule(def.config as SynonymsRuleConfig),
precedence: (def) =>
new PrecedenceRule(def.config as PrecedenceRuleConfig, {
maxTokens: 3,
}),
};This lets the engine dynamically build the correct rule objects at runtime.
Your configuration is passed into buildEngineFromConfig, which:
- Looks up the factory for each rule type.
- Instantiates the runtime rule instance.
- Creates a new
RuleEnginewith a list of rule objects.
export const engineFromConfig = buildEngineFromConfig(config);The rule engine processes the initial context through each rule in order:
const initialCtx = { tokens: ["a1", "b1", "c1", "a2", "z1", "a3", "c4", "b3"] };
const result = await engineFromConfig.run(initialCtx);Normalizes tokens so all variants map to a canonical token.
["a1", "a3", "b1"] → ["A", "A", "B"]Sorts and filters tokens based on a precedence list, with an optional max token count.
Example:
precedence: ["A", "B", "C", "Z"]
tokens: ["A", "Z", "B", "A", "C"]
→ ["A", "B", "C"] // when maxTokens: 3Adding a new rule follows 4 steps.
In types.ts:
export interface MyNewRuleConfig {
type: "my-new-rule";
threshold: number;
}Create rules/my-new-rule.ts:
import { Rule } from "../types";
import { RuleContext } from "../engine";
import { MyNewRuleConfig } from "../types";
export class MyNewRule implements Rule<RuleContext> {
public readonly type = "my-new-rule" as const;
constructor(private readonly config: MyNewRuleConfig) {}
apply(ctx: RuleContext): RuleContext {
const { threshold } = this.config;
const tokens = ctx.tokens.filter((t) => t.length >= threshold);
return {
...ctx,
tokens,
};
}
}In rule-registry.ts:
import { MyNewRule } from "./rules/my-new-rule";
const ruleRegistry: Record<RuleType, RuleFactory<RuleContext>> = {
...,
"my-new-rule": def =>
new MyNewRule(def.config as MyNewRuleConfig)
};{
type: "my-new-rule",
config: { threshold: 2 }
}You can easily extend the rule engine with additional capabilities:
- Async rules: Rules can await external services or databases.
- Branching / conditional rules: Evaluate rules only if a condition is met.
- Rule groups: Run groups of rules parallelly or sequentially.
- Rule debugging hooks: Add before, after, and onError logic.
- Context enrichment: Rules may attach extra fields to context objects.