ts-10-rule-engine
1.000
Challenge · difficulty 5/5
# Typed rule / query engine
Implement a file **`solution.ts`** that interprets a small, tree-shaped rule language
over plain records and uses it to filter collections of records.
## Types to export
```ts
export type Rule =
| { op: "and"; rules: Rule[] }
| { op: "or"; rules: Rule[] }
| { op: "not"; rule: Rule }
| { op: "eq" | "ne" | "lt" | "lte" | "gt" | "gte"; field: string; value: number | string }
| { op: "in"; field: string; values: Array<number | string> };
export class RuleError extends Error {}
export function evaluate(rule: Rule, record: Record<string, unknown>): boolean;
export function filter(
rule: Rule,
records: Array<Record<string, unknown>>,
): Array<Record<string, unknown>>;
```
## Semantics
`evaluate(rule, record)` returns a boolean by recursively interpreting `rule` against a
single `record`.
### Logical combinators
- `and`: true iff **every** sub-rule in `rules` is true. An empty `rules` array is
**true** (the identity of conjunction).
- `or`: true iff **any** sub-rule in `rules` is true. An empty `rules` array is
**false** (the identity of disjunction).
- `not`: the negation of its single child `rule`.
### Comparisons
Comparison ops look up `record[field]`. Let `fieldValue = record[field]`.
- `eq`: true iff `fieldValue === value`.
- `ne`: true iff `fieldValue !== value`.
- `lt` / `lte` / `gt` / `gte`: ordering comparisons. They are evaluated **only** when
`fieldValue` and `value` have the same comparable type:
- if **both** are numbers, compare numerically;
- if **both** are strings, compare lexicographically (with `<`, `<=`, `>`, `>=`);
- otherwise (the field is missing/`undefined`, or one side is a number and the other
a string, or the field holds some other type) the comparison is **`false`**. It does
**not** throw.
So `{ op: "gt", field: "age", value: 18 }` is `false` for a record with no `age`, and
also `false` if `age` is the string `"20"` (string vs number mismatch).
### Membership
- `in`: true iff `record[field]` is **strictly equal** (`===`) to at least one element
of `values`.
### Validation — when to throw `RuleError`
A rule node is invalid if it is not a well-formed member of the `Rule` union. Invalid
nodes must cause `evaluate` (and therefore `filter`) to throw a `RuleError` (not a plain
`Error`). In particular, throw `RuleError` when:
- `op` is missing or is not one of the known operators;
- `and` / `or` does not carry an array `rules`;
- `not` does not carry a child `rule` object;
- a comparison op (`eq`/`ne`/`lt`/`lte`/`gt`/`gte`) does not carry a string `field`, or
its `value` is neither a number nor a string;
- `in` does not carry a string `field` or an array `values`.
You may validate up front or lazily as you evaluate; either is fine, as long as invalid
rules raise `RuleError`.
## `filter`
`filter(rule, records)` returns a new array containing exactly the records for which
`evaluate(rule, record)` is `true`, **in their original order**. If `rule` is invalid it
propagates the `RuleError`.
## Worked example
```ts
const rule: Rule = {
op: "and",
rules: [
{ op: "gte", field: "age", value: 18 },
{
op: "or",
rules: [
{ op: "eq", field: "country", value: "US" },
{ op: "in", field: "country", values: ["CA", "MX"] },
],
},
{ op: "not", rule: { op: "eq", field: "banned", value: 1 } },
],
};
const people = [
{ name: "a", age: 25, country: "US", banned: 0 }, // kept
{ name: "b", age: 17, country: "US", banned: 0 }, // dropped: age < 18
{ name: "c", age: 40, country: "CA", banned: 1 }, // dropped: banned
{ name: "d", age: 30, country: "FR", banned: 0 }, // dropped: country
{ name: "e", age: 22, country: "MX", banned: 0 }, // kept
];
filter(rule, people); // -> [ {name:"a",...}, {name:"e",...} ]
```
Keep it fully typed: `solution.ts` must pass `tsc --noEmit` in strict mode. Do not use
`any` in the implementation; prefer `unknown` and narrow.
tests/solution.test.ts
import { test } from "node:test";
import { strict as assert } from "node:assert";
import { evaluate, filter, RuleError, type Rule } from "./solution.ts";
test("eq / ne on numbers and strings", () => {
assert.equal(evaluate({ op: "eq", field: "a", value: 1 }, { a: 1 }), true);
assert.equal(evaluate({ op: "eq", field: "a", value: 1 }, { a: 2 }), false);
assert.equal(evaluate({ op: "ne", field: "a", value: 1 }, { a: 2 }), true);
assert.equal(evaluate({ op: "eq", field: "s", value: "x" }, { s: "x" }), true);
assert.equal(evaluate({ op: "ne", field: "s", value: "x" }, { s: "y" }), true);
});
test("eq does not coerce types (number vs string)", () => {
assert.equal(evaluate({ op: "eq", field: "a", value: 1 }, { a: "1" }), false);
assert.equal(evaluate({ op: "ne", field: "a", value: 1 }, { a: "1" }), true);
});
test("numeric lt / lte / gt / gte", () => {
assert.equal(evaluate({ op: "lt", field: "n", value: 10 }, { n: 5 }), true);
assert.equal(evaluate({ op: "lt", field: "n", value: 10 }, { n: 10 }), false);
assert.equal(evaluate({ op: "lte", field: "n", value: 10 }, { n: 10 }), true);
assert.equal(evaluate({ op: "gt", field: "n", value: 10 }, { n: 11 }), true);
assert.equal(evaluate({ op: "gt", field: "n", value: 10 }, { n: 10 }), false);
assert.equal(evaluate({ op: "gte", field: "n", value: 10 }, { n: 10 }), true);
});
test("lexicographic string ordering", () => {
assert.equal(evaluate({ op: "lt", field: "s", value: "banana" }, { s: "apple" }), true);
assert.equal(evaluate({ op: "gt", field: "s", value: "apple" }, { s: "banana" }), true);
assert.equal(evaluate({ op: "gte", field: "s", value: "apple" }, { s: "apple" }), true);
assert.equal(evaluate({ op: "lte", field: "s", value: "apple" }, { s: "apple" }), true);
});
test("ordering with missing field is false", () => {
assert.equal(evaluate({ op: "gt", field: "age", value: 18 }, {}), false);
assert.equal(evaluate({ op: "lt", field: "age", value: 18 }, {}), false);
assert.equal(evaluate({ op: "lte", field: "age", value: 18 }, {}), false);
assert.equal(evaluate({ op: "gte", field: "age", value: 18 }, {}), false);
});
test("ordering with type mismatch is false (no throw)", () => {
// number value, string field
assert.equal(evaluate({ op: "gt", field: "age", value: 18 }, { age: "20" }), false);
// string value, number field
assert.equal(evaluate({ op: "lt", field: "age", value: "20" }, { age: 18 }), false);
// field holds a non-comparable type
assert.equal(evaluate({ op: "gt", field: "age", value: 1 }, { age: true }), false);
});
test("eq with missing field is false, ne is true", () => {
assert.equal(evaluate({ op: "eq", field: "x", value: 1 }, {}), false);
assert.equal(evaluate({ op: "ne", field: "x", value: 1 }, {}), true);
});
test("in membership", () => {
assert.equal(evaluate({ op: "in", field: "c", values: ["US", "CA"] }, { c: "CA" }), true);
assert.equal(evaluate({ op: "in", field: "c", values: ["US", "CA"] }, { c: "FR" }), false);
assert.equal(evaluate({ op: "in", field: "n", values: [1, 2, 3] }, { n: 2 }), true);
// strict equality: "1" is not 1
assert.equal(evaluate({ op: "in", field: "n", values: [1, 2, 3] }, { n: "1" }), false);
// empty values -> never matches
assert.equal(evaluate({ op: "in", field: "n", values: [] }, { n: 1 }), false);
});
test("not negates its child", () => {
assert.equal(evaluate({ op: "not", rule: { op: "eq", field: "a", value: 1 } }, { a: 1 }), false);
assert.equal(evaluate({ op: "not", rule: { op: "eq", field: "a", value: 1 } }, { a: 2 }), true);
});
test("empty and is true, empty or is false", () => {
assert.equal(evaluate({ op: "and", rules: [] }, {}), true);
assert.equal(evaluate({ op: "or", rules: [] }, {}), false);
});
test("and / or short-circuit semantics over multiple rules", () => {
const r: Rule = {
op: "and",
rules: [
{ op: "gte", field: "age", value: 18 },
{ op: "eq", field: "country", value: "US" },
],
};
assert.equal(evaluate(r, { age: 20, country: "US" }), true);
assert.equal(evaluate(r, { age: 17, country: "US" }), false);
assert.equal(evaluate(r, { age: 20, country: "FR" }), false);
const o: Rule = {
op: "or",
rules: [
{ op: "eq", field: "country", value: "US" },
{ op: "eq", field: "country", value: "CA" },
],
};
assert.equal(evaluate(o, { country: "CA" }), true);
assert.equal(evaluate(o, { country: "FR" }), false);
});
test("deeply nested and / or / not", () => {
const rule: Rule = {
op: "and",
rules: [
{ op: "gte", field: "age", value: 18 },
{
op: "or",
rules: [
{ op: "eq", field: "country", value: "US" },
{ op: "in", field: "country", values: ["CA", "MX"] },
],
},
{ op: "not", rule: { op: "eq", field: "banned", value: 1 } },
],
};
assert.equal(evaluate(rule, { age: 25, country: "US", banned: 0 }), true);
assert.equal(evaluate(rule, { age: 17, country: "US", banned: 0 }), false);
assert.equal(evaluate(rule, { age: 40, country: "CA", banned: 1 }), false);
assert.equal(evaluate(rule, { age: 30, country: "FR", banned: 0 }), false);
assert.equal(evaluate(rule, { age: 22, country: "MX", banned: 0 }), true);
});
test("filter keeps matching records in original order", () => {
const rule: Rule = {
op: "and",
rules: [
{ op: "gte", field: "age", value: 18 },
{
op: "or",
rules: [
{ op: "eq", field: "country", value: "US" },
{ op: "in", field: "country", values: ["CA", "MX"] },
],
},
{ op: "not", rule: { op: "eq", field: "banned", value: 1 } },
],
};
const people = [
{ name: "a", age: 25, country: "US", banned: 0 },
{ name: "b", age: 17, country: "US", banned: 0 },
{ name: "c", age: 40, country: "CA", banned: 1 },
{ name: "d", age: 30, country: "FR", banned: 0 },
{ name: "e", age: 22, country: "MX", banned: 0 },
];
assert.deepEqual(
filter(rule, people).map((r) => r["name"]),
["a", "e"],
);
});
test("filter with empty-and matches everything; empty-or matches nothing", () => {
const recs = [{ a: 1 }, { a: 2 }, { a: 3 }];
assert.deepEqual(filter({ op: "and", rules: [] }, recs), recs);
assert.deepEqual(filter({ op: "or", rules: [] }, recs), []);
});
test("RuleError thrown for an unknown op", () => {
assert.throws(() => evaluate({ op: "wat" } as unknown as Rule, {}), RuleError);
});
test("RuleError thrown for malformed and / not / comparison nodes", () => {
// and without an array rules
assert.throws(() => evaluate({ op: "and" } as unknown as Rule, {}), RuleError);
// not without a child rule
assert.throws(() => evaluate({ op: "not" } as unknown as Rule, {}), RuleError);
// comparison without a string field
assert.throws(() => evaluate({ op: "eq", value: 1 } as unknown as Rule, {}), RuleError);
// comparison with a non number/string value
assert.throws(
() => evaluate({ op: "gt", field: "a", value: true } as unknown as Rule, {}),
RuleError,
);
// in without an array values
assert.throws(() => evaluate({ op: "in", field: "a" } as unknown as Rule, {}), RuleError);
// missing op entirely
assert.throws(() => evaluate({} as unknown as Rule, {}), RuleError);
});
test("filter propagates RuleError from an invalid rule", () => {
assert.throws(() => filter({ op: "nope" } as unknown as Rule, [{ a: 1 }]), RuleError);
});
System prompt
You are an expert programmer. Solve the task exactly as specified. Output your solution as fenced code blocks using the required file name(s) and the exact function/type signatures requested. Prefer correctness; do not include prose outside code unless asked.
Proposed solution
```typescript
export type Rule =
| { op: "and"; rules: Rule[] }
| { op: "or"; rules: Rule[] }
| { op: "not"; rule: Rule }
| { op: "eq" | "ne" | "lt" | "lte" | "gt" | "gte"; field: string; value: number | string }
| { op: "in"; field: string; values: Array<number | string> };
export class RuleError extends Error {}
function validateRule(rule: Rule): void {
switch (rule.op) {
case "and":
if (!Array.isArray(rule.rules)) {
throw new RuleError("and rule must have a rules array");
}
for (const subRule of rule.rules) {
validateRule(subRule);
}
break;
case "or":
if (!Array.isArray(rule.rules)) {
throw new RuleError("or rule must have a rules array");
}
for (const subRule of rule.rules) {
validateRule(subRule);
}
break;
case "not":
if (!("rule" in rule)) {
throw new RuleError("not rule must have a rule child");
}
validateRule(rule.rule);
break;
case "eq":
case "ne":
case "lt":
case "lte":
case "gt":
case "gte": {
if (typeof rule.field !== "string") {
throw new RuleError("comparison rule must have a string field");
}
if (typeof rule.value !== "number" && typeof rule.value !== "string") {
throw new RuleError("comparison rule value must be number or string");
}
break;
}
case "in": {
if (typeof rule.field !== "string") {
throw new RuleError("in rule must have a string field");
}
if (!Array.isArray(rule.values)) {
throw new RuleError("in rule must have a values array");
}
for (const val of rule.values) {
if (typeof val !== "number" && typeof val !== "string") {
throw new RuleError("in rule values must be number or string");
}
}
break;
}
default:
throw new RuleError(`Unknown operator: ${rule.op}`);
}
}
function evaluateAnd(rule: { op: "and"; rules: Rule[] }, record: Record<string, unknown>): boolean {
for (const subRule of rule.rules) {
if (!evaluate(subRule, record)) {
return false;
}
}
return true;
}
function evaluateOr(rule: { op: "or"; rules: Rule[] }, record: Record<string, unknown>): boolean {
for (const subRule of rule.rules) {
if (evaluate(subRule, record)) {
return true;
}
}
return false;
}
function evaluateNot(rule: { op: "not"; rule: Rule }, record: Record<string, unknown>): boolean {
return !evaluate(rule.rule, record);
}
function evaluateEq(rule: { op: "eq"; field: string; value: number | string }, record: Record<string, unknown>): boolean {
const fieldValue = record[rule.field];
return fieldValue === rule.value;
}
function evaluateNe(rule: { op: "ne"; field: string; value: number | string }, record: Record<string, unknown>): boolean {
const fieldValue = record[rule.field];
return fieldValue !== rule.value;
}
function evaluateLt(rule: { op: "lt"; field: string; value: number | string }, record: Record<string, unknown>): boolean {
const fieldValue = record[rule.field];
if (typeof fieldValue !== "number" && typeof fieldValue !== "string") {
return false;
}
if (typeof fieldValue !== typeof rule.value) {
return false;
}
return fieldValue < rule.value;
}
function evaluateLte(rule: { op: "lte"; field: string; value: number | string }, record: Record<string, unknown>): boolean {
const fieldValue = record[rule.field];
if (typeof fieldValue !== "number" && typeof fieldValue !== "string") {
return false;
}
if (typeof fieldValue !== typeof rule.value) {
return false;
}
return fieldValue <= rule.value;
}
function evaluateGt(rule: { op: "gt"; field: string; value: number | string }, record: Record<string, unknown>): boolean {
const fieldValue = record[rule.field];
if (typeof fieldValue !== "number" && typeof fieldValue !== "string") {
return false;
}
if (typeof fieldValue !== typeof rule.value) {
return false;
}
return fieldValue > rule.value;
}
function evaluateGte(rule: { op: "gte"; field: string; value: number | string }, record: Record<string, unknown>): boolean {
const fieldValue = record[rule.field];
if (typeof fieldValue !== "number" && typeof fieldValue !== "string") {
return false;
}
if (typeof fieldValue !== typeof rule.value) {
return false;
}
return fieldValue >= rule.value;
}
function evaluateIn(rule: { op: "in"; field: string; values: Array<number | string> }, record: Record<string, unknown>): boolean {
const fieldValue = record[rule.field];
if (fieldValue === undefined) {
return false;
}
for (const val of rule.values) {
if (fieldValue === val) {
return true;
}
}
return false;
}
export function evaluate(rule: Rule, record: Record<string, unknown>): boolean {
validateRule(rule);
switch (rule.op) {
case "and":
return evaluateAnd(rule as { op: "and"; rules: Rule[] }, record);
case "or":
return evaluateOr(rule as { op: "or"; rules: Rule[] }, record);
case "not":
return evaluateNot(rule as { op: "not"; rule: Rule }, record);
case "eq":
return evaluateEq(rule as { op: "eq"; field: string; value: number | string }, record);
case "ne":
return evaluateNe(rule as { op: "ne"; field: string; value: number | string }, record);
case "lt":
return evaluateLt(rule as { op: "lt"; field: string; value: number | string }, record);
case "lte":
return evaluateLte(rule as { op: "lte"; field: string; value: number | string }, record);
case "gt":
return evaluateGt(rule as { op: "gt"; field: string; value: number | string }, record);
case "gte":
return evaluateGte(rule as { op: "gte"; field: string; value: number | string }, record);
case "in":
return evaluateIn(rule as { op: "in"; field: string; values: Array<number | string> }, record);
default:
throw new RuleError(`Unknown operator: ${rule.op}`);
}
}
export function filter(rule: Rule, records: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
validateRule(rule);
return records.filter(record => evaluate(rule, record));
}
```Test output (stdout)
TAP version 13 # Subtest: eq / ne on numbers and strings ok 1 - eq / ne on numbers and strings --- duration_ms: 0.486261 type: 'test' ... # Subtest: eq does not coerce types (number vs string) ok 2 - eq does not coerce types (number vs string) --- duration_ms: 0.06145 type: 'test' ... # Subtest: numeric lt / lte / gt / gte ok 3 - numeric lt / lte / gt / gte --- duration_ms: 0.07914 type: 'test' ... # Subtest: lexicographic string ordering ok 4 - lexicographic string ordering --- duration_ms: 0.04069 type: 'test' ... # Subtest: ordering with missing field is false ok 5 - ordering with missing field is false --- duration_ms: 0.04565 type: 'test' ... # Subtest: ordering with type mismatch is false (no throw) ok 6 - ordering with type mismatch is false (no throw) --- duration_ms: 0.03939 type: 'test' ... # Subtest: eq with missing field is false, ne is true ok 7 - eq with missing field is false, ne is true --- duration_ms: 0.04377 type: 'test' ... # Subtest: in membership ok 8 - in membership --- duration_ms: 0.07387 type: 'test' ... # Subtest: not negates its child ok 9 - not negates its child --- duration_ms: 0.0621 type: 'test' ... # Subtest: empty and is true, empty or is false ok 10 - empty and is true, empty or is false --- duration_ms: 0.109261 type: 'test' ... # Subtest: and / or short-circuit semantics over multiple rules ok 11 - and / or short-circuit semantics over multiple rules --- duration_ms: 0.0674 type: 'test' ... # Subtest: deeply nested and / or / not ok 12 - deeply nested and / or / not --- duration_ms: 0.07211 type: 'test' ... # Subtest: filter keeps matching records in original order ok 13 - filter keeps matching records in original order --- duration_ms: 0.368631 type: 'test' ... # Subtest: filter with empty-and matches everything; empty-or matches nothing ok 14 - filter with empty-and matches everything; empty-or matches nothing --- duration_ms: 0.05733 type: 'test' ... # Subtest: RuleError thrown for an unknown op ok 15 - RuleError thrown for an unknown op --- duration_ms: 0.17853 type: 'test' ... # Subtest: RuleError thrown for malformed and / not / comparison nodes ok 16 - RuleError thrown for malformed and / not / comparison nodes --- duration_ms: 0.10762 type: 'test' ... # Subtest: filter propagates RuleError from an invalid rule ok 17 - filter propagates RuleError from an invalid rule --- duration_ms: 0.03977 type: 'test' ... 1..17 # tests 17 # suites 0 # pass 17 # fail 0 # cancelled 0 # skipped 0 # todo 0 # duration_ms 79.989736
Reasoning (chain-of-thought)
The user wants me to implement a typed rule/query engine in TypeScript. Let me break down the requirements:
1. **Types to export:**
- `Rule` union type with various operators
- `RuleError` class extending Error
- `evaluate(rule, record)` function
- `filter(rule, records)` function
2. **Semantics:**
- Logical combinators: `and`, `or`, `not`
- Comparisons: `eq`, `ne`, `lt`, `lte`, `gt`, `gte`
- Membership: `in`
3. **Validation:**
- Must throw `RuleError` for invalid rules
- Various validation rules for each operator type
4. **Comparison semantics:**
- Only compare if both values are the same type (both numbers or both strings)
- Otherwise return `false` (don't throw)
5. **Type safety:**
- No `any` types
- Use `unknown` and narrow
- Must pass `tsc --noEmit` in strict mode
Let me implement this step by step:
```typescript
export type Rule =
| { op: "and"; rules: Rule[] }
| { op: "or"; rules: Rule[] }
| { op: "not"; rule: Rule }
| { op: "eq" | "ne" | "lt" | "lte" | "gt" | "gte"; field: string; value: number | string }
| { op: "in"; field: string; values: Array<number | string> };
export class RuleError extends Error {}
export function evaluate(rule: Rule, record: Record<string, unknown>): boolean {
// Validate the rule structure
validateRule(rule);
switch (rule.op) {
case "and":
return evaluateAnd(rule as { op: "and"; rules: Rule[] }, record);
case "or":
return evaluateOr(rule as { op: "or"; rules: Rule[] }, record);
case "not":
return !evaluate(rule as { op: "not"; rule: Rule }, record);
case "eq":
return evaluateEq(rule as { op: "eq"; field: string; value: number | string }, record);
case "ne":
return evaluateNe(rule as { op: "ne"; field: string; value: number | string }, record);
case "lt":
return evaluateLt(rule as { op: "lt"; field: string; value: number | string }, record);
case "lte":
return evaluateLte(rule as { op: "lte"; field: string; value: number | string }, record);
case "gt":
return evaluateGt(rule as { op: "gt"; field: string; value: number | string }, record);
case "gte":
return evaluateGte(rule as { op: "gte"; field: string; value: number | string }, record);
case "in":
return evaluateIn(rule as { op: "in"; field: string; values: Array<number | string> }, record);
default:
throw new RuleError(`Unknown operator: ${rule.op}`);
}
}
```
I'll continue implementing the validation and evaluation logic for each operator type. The key is to ensure type safety and proper error handling while maintaining the specified semantics.
The validation function will check the structure of each rule type, ensuring that all required fields are present and correctly typed. This helps prevent runtime errors and maintains the integrity of the rule evaluation process.
For logical operators like `and` and `or`, I'll verify that the rules array exists and contains valid Rule objects. The `not` operator requires a single rule to negate.
Comparison operators need specific field and value type checks. I'll ensure the field is a string and the value is either a number or string. The `in` operator requires a string field and an array of values.
The evaluation functions will handle the actual logic for each operator type, checking conditions against the provided record.