← run

ts-10-rule-engine

1.000
17/17 tests· architecture
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.