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.472586
type: 'test'
...
# Subtest: eq does not coerce types (number vs string)
ok 2 - eq does not coerce types (number vs string)
---
duration_ms: 0.058111
type: 'test'
...
# Subtest: numeric lt / lte / gt / gte
ok 3 - numeric lt / lte / gt / gte
---
duration_ms: 0.073101
type: 'test'
...
# Subtest: lexicographic string ordering
ok 4 - lexicographic string ordering
---
duration_ms: 0.041011
type: 'test'
...
# Subtest: ordering with missing field is false
ok 5 - ordering with missing field is false
---
duration_ms: 0.04266
type: 'test'
...
# Subtest: ordering with type mismatch is false (no throw)
ok 6 - ordering with type mismatch is false (no throw)
---
duration_ms: 0.037431
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.041471
type: 'test'
...
# Subtest: in membership
ok 8 - in membership
---
duration_ms: 0.070301
type: 'test'
...
# Subtest: not negates its child
ok 9 - not negates its child
---
duration_ms: 0.059031
type: 'test'
...
# Subtest: empty and is true, empty or is false
ok 10 - empty and is true, empty or is false
---
duration_ms: 0.097181
type: 'test'
...
# Subtest: and / or short-circuit semantics over multiple rules
ok 11 - and / or short-circuit semantics over multiple rules
---
duration_ms: 0.062561
type: 'test'
...
# Subtest: deeply nested and / or / not
ok 12 - deeply nested and / or / not
---
duration_ms: 0.068541
type: 'test'
...
# Subtest: filter keeps matching records in original order
ok 13 - filter keeps matching records in original order
---
duration_ms: 0.410855
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.06704
type: 'test'
...
# Subtest: RuleError thrown for an unknown op
ok 15 - RuleError thrown for an unknown op
---
duration_ms: 0.178752
type: 'test'
...
# Subtest: RuleError thrown for malformed and / not / comparison nodes
ok 16 - RuleError thrown for malformed and / not / comparison nodes
---
duration_ms: 0.105551
type: 'test'
...
# Subtest: filter propagates RuleError from an invalid rule
ok 17 - filter propagates RuleError from an invalid rule
---
duration_ms: 0.037341
type: 'test'
...
1..17
# tests 17
# suites 0
# pass 17
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 82.737615
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.