← 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 {}

export function evaluate(rule: Rule, record: Record<string, unknown>): boolean {
  // Validate that rule is an object
  if (typeof rule !== "object" || rule === null) {
    throw new RuleError("Rule must be an object");
  }

  switch (rule.op) {
    case "and": {
      // Runtime check: rules must be an array
      if (!Array.isArray(rule.rules as unknown)) {
        throw new RuleError("and requires rules to be an array");
      }
      // Identity: empty array is true
      if (rule.rules.length === 0) return true;
      for (const subRule of rule.rules) {
        if (!evaluate(subRule, record)) return false;
      }
      return true;
    }
    case "or": {
      // Runtime check: rules must be an array
      if (!Array.isArray(rule.rules as unknown)) {
        throw new RuleError("or requires rules to be an array");
      }
      // Identity: empty array is false
      if (rule.rules.length === 0) return false;
      for (const subRule of rule.rules) {
        if (evaluate(subRule, record)) return true;
      }
      return false;
    }
    case "not": {
      // Runtime check: rule must be an object
      if (typeof (rule.rule as unknown) !== "object" || rule.rule === null) {
        throw new RuleError("not requires a rule object");
      }
      return !evaluate(rule.rule, record);
    }
    case "eq":
    case "ne":
    case "lt":
    case "lte":
    case "gt":
    case "gte": {
      // Runtime check: field must be string
      if (typeof (rule.field as unknown) !== "string") {
        throw new RuleError("Comparison requires field to be a string");
      }
      // Runtime check: value must be number or string
      if (typeof (rule.value as unknown) !== "number" && typeof (rule.value as unknown) !== "string") {
        throw new RuleError("Comparison requires value to be number or string");
      }

      const fieldValue = record[rule.field];

      if (rule.op === "eq") return fieldValue === rule.value;
      if (rule.op === "ne") return fieldValue !== rule.value;

      // Ordering comparisons: only if types match (both number or both string)
      if (
        (typeof fieldValue === "number" && typeof rule.value === "number") ||
        (typeof fieldValue === "string" && typeof rule.value === "string")
      ) {
        const fv = fieldValue as number | string;
        const rv = rule.value as number | string;
        switch (rule.op) {
          case "lt": return fv < rv;
          case "lte": return fv <= rv;
          case "gt": return fv > rv;
          case "gte": return fv >= rv;
        }
      }
      // Mismatched types or missing field -> false
      return false;
    }
    case "in": {
      // Runtime check: field must be string
      if (typeof (rule.field as unknown) !== "string") {
        throw new RuleError("in requires field to be a string");
      }
      // Runtime check: values must be array
      if (!Array.isArray(rule.values as unknown)) {
        throw new RuleError("in requires values to be an array");
      }
      // Runtime check: values elements must be number or string
      for (const v of rule.values as unknown[]) {
        if (typeof v !== "number" && typeof v !== "string") {
          throw new RuleError("in values must be number or string");
        }
      }

      const fieldValue = record[rule.field];
      return rule.values.some(v => v === fieldValue);
    }
    default:
      // Handle invalid operators (e.g. from casted objects)
      const r = rule as unknown;
      const op = (r as Record<string, unknown>).op;
      throw new RuleError(`Unknown operator: ${op}`);
  }
}

export function filter(
  rule: Rule,
  records: Array<Record<string, unknown>>,
): Array<Record<string, unknown>> {
  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.477268
  type: 'test'
  ...
# Subtest: eq does not coerce types (number vs string)
ok 2 - eq does not coerce types (number vs string)
  ---
  duration_ms: 0.058651
  type: 'test'
  ...
# Subtest: numeric lt / lte / gt / gte
ok 3 - numeric lt / lte / gt / gte
  ---
  duration_ms: 0.054091
  type: 'test'
  ...
# Subtest: lexicographic string ordering
ok 4 - lexicographic string ordering
  ---
  duration_ms: 0.042421
  type: 'test'
  ...
# Subtest: ordering with missing field is false
ok 5 - ordering with missing field is false
  ---
  duration_ms: 0.04637
  type: 'test'
  ...
# Subtest: ordering with type mismatch is false (no throw)
ok 6 - ordering with type mismatch is false (no throw)
  ---
  duration_ms: 0.04091
  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.046511
  type: 'test'
  ...
# Subtest: in membership
ok 8 - in membership
  ---
  duration_ms: 0.067061
  type: 'test'
  ...
# Subtest: not negates its child
ok 9 - not negates its child
  ---
  duration_ms: 0.047831
  type: 'test'
  ...
# Subtest: empty and is true, empty or is false
ok 10 - empty and is true, empty or is false
  ---
  duration_ms: 0.073112
  type: 'test'
  ...
# Subtest: and / or short-circuit semantics over multiple rules
ok 11 - and / or short-circuit semantics over multiple rules
  ---
  duration_ms: 0.080152
  type: 'test'
  ...
# Subtest: deeply nested and / or / not
ok 12 - deeply nested and / or / not
  ---
  duration_ms: 0.056991
  type: 'test'
  ...
# Subtest: filter keeps matching records in original order
ok 13 - filter keeps matching records in original order
  ---
  duration_ms: 0.336336
  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.053111
  type: 'test'
  ...
# Subtest: RuleError thrown for an unknown op
ok 15 - RuleError thrown for an unknown op
  ---
  duration_ms: 0.194433
  type: 'test'
  ...
# Subtest: RuleError thrown for malformed and / not / comparison nodes
ok 16 - RuleError thrown for malformed and / not / comparison nodes
  ---
  duration_ms: 0.115973
  type: 'test'
  ...
# Subtest: filter propagates RuleError from an invalid rule
ok 17 - filter propagates RuleError from an invalid rule
  ---
  duration_ms: 0.04077
  type: 'test'
  ...
1..17
# tests 17
# suites 0
# pass 17
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 76.457739
Reasoning (chain-of-thought)
The user wants a TypeScript file `solution.ts` that implements a typed rule/query engine.

Key requirements:
1. Export types: `Rule`, `RuleError`, `evaluate`, `filter`.
2. `Rule` is a discriminated union with logical operators (`and`, `or`, `not`), comparison operators (`eq`, `ne`, `lt`, `lte`, `gt`, `gte`), and membership (`in`).
3. `evaluate(rule, record)` recursively interprets the rule against a record.
4. `filter(rule, records)` returns records matching the rule.
5. Specific semantics for logical combinators (empty arrays behavior).
6. Specific semantics for comparisons (type checking, strict equality, handling missing fields).
7. Specific semantics for `in` (strict equality).
8. Validation: throw `RuleError` for invalid rule structures.
9. No `any` allowed. Use `unknown` and narrowing.
10. Must pass `tsc --noEmit` in strict mode.

Let's break down the implementation details.

### Types

```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 {}
```

### Validation Logic

We need to validate the structure of the rule. Since `Rule` is a union type, TypeScript's type system helps, but at runtime, the input might be malformed (e.g., `{ op: "unknown" }` or `{ op: "and", rules: "not an array" }`). The prompt says "A rule node is invalid if it is not a well-formed member of the Rule union."

We can write a helper function `validateRule(rule: unknown): asserts rule is Rule` or just check inside `evaluate`. Since `evaluate` takes `rule: Rule`, TypeScript assumes it's valid at compile time. However, the prompt implies runtime validation is needed because "Invalid nodes must cause evaluate ... to throw a RuleError". This suggests the input might be passed as `unknown` or a loose object that claims to be a `Rule` but isn't structurally correct according to the runtime checks described (e.g., `op` is "and" but `rules` is missing).

Wait, the signature is `evaluate(rule: Rule, ...)`. If the caller passes a malformed object, TypeScript won't catch it if they cast it or if it's from JSON. The prompt explicitly asks to throw `RuleError` for specific structural issues. So we must perform runtime checks on the object passed as `rule`, even though its type is `Rule`.

Let's define a validation function.

```typescript
function validate(rule: unknown): asserts rule is Rule {
  if (typeof rule !== "object" || rule === null) {
    throw new RuleError("Rule must be an object");
  }
  const r = rule as Record<string, unknown>;
  const op = r.op;

  if (typeof op !== "string") {
    throw new RuleError("Missing or invalid op");
  }

  switch (op) {
    case "and":
    case "or":
      if (!Array.isArray(r.rules)) {
        throw new RuleError(`${op} requires rules array`);
      }
      // Recursively validate rules? The prompt says "A rule node is invalid...". 
      // It's safer to validate recursively or lazily. 
      // "You may validate up front or lazily as you evaluate".
      // Let's do lazy validation inside evaluate to keep it simple, 
      // or a helper that checks structure.
      // Actually, if we just check the current node's structure in evaluate, that satisfies "lazy".
      break;
    case "not":
      if (typeof r.rule !== "object" || r.rule === null) {
        throw new RuleError("not requires rule object");
      }
      break;
    case "eq":
    case "ne":
    case "lt":
    case "lte":
    case "gt":
    case "gte":
      if (typeof r.field !== "string") {
        throw new RuleError("Comparison requires string field");
      }
      if (typeof r.value !== "number" && typeof r.value !== "string") {
        throw new RuleError("Comparison requires number or string value");
      }
      break;
    case "in":
      if (typeof r.field !== "string") {
        throw new RuleError("in requires string field");
      }
      if (!Array.isArray(r.values)) {
        throw new RuleError("in requires values array");
      }
      // Check values types? "values: Array<number | string>". 
      // If an element is boolean, is it invalid? 
      // The type says Array<number | string>. 
      // Let's assume strict adherence to type definition implies elements must be number|string.
      // But usually runtime validation focuses on structure. 
      // Let's check elements just in case.
      for (const v of r.values) {
        if (typeof v !== "number" && typeof v !== "string") {
          throw new RuleError("in values must be number or string");
        }
      }
      break;
    default:
      throw new RuleError(`Unknown op: ${op}`);
  }
}
```

However, since the function signature is `evaluate(rule: Rule, ...)`, TypeScript narrows `rule` based on `rule.op`. If we rely on TypeScript narrowing, we might miss runtime checks if the object is structurally incorrect (e.g. `op: "and"` but `rules` is undefined).
So, inside `evaluate`, we should check the properties required by the specific `op` branch.

### Implementation of `evaluate`

```typescript
export function evaluate(rule: Rule, record: Record<string, unknown>): boolean {
  // Runtime validation of the current node structure
  // Since 'rule' is typed as Rule, we check specific properties based on op.
  
  switch (rule.op) {
    case "and": {
      // Validate rules is array
      if (!Array.isArray(rule.rules)) throw new RuleError("and requires rules array");
      // Empty rules is true
      if (rule.rules.length === 0) return true;
      for (const subRule of rule.rules) {
        if (!evaluate(subRule, record)) return false;
      }
      return true;
    }
    case "or": {
      if (!Array.isArray(rule.rules)) throw new RuleError("or requires rules array");
      // Empty rules is false
      if (rule.rules.length === 0) return false;
      for (const subRule of rule.rules) {
        if (evaluate(subRule, record)) return true;
      }
      return false;
    }
    case "not": {
      if (typeof rule.rule !== "
…[45319 chars elided]…

      }

      const fieldValue = record[rule.field];

      if (rule.op === "eq") return fieldValue === rule.value;
      if (rule.op === "ne") return fieldValue !== rule.value;

      if (
        (typeof fieldValue === "number" && typeof rule.value === "number") ||
        (typeof fieldValue === "string" && typeof rule.value === "string")
      ) {
        const fv = fieldValue as number | string;
        const rv = rule.value as number | string;
        switch (rule.op) {
          case "lt": return fv < rv;
          case "lte": return fv <= rv;
          case "gt": return fv > rv;
          case "gte": return fv >= rv;
        }
      }
      return false;
    }
    case "in": {
      if (typeof (rule.field as unknown) !== "string") {
        throw new RuleError("in requires field to be a string");
      }
      if (!Array.isArray(rule.values as unknown)) {
        throw new RuleError("in requires values to be an array");
      }
      for (const v of rule.values as unknown[]) {
        if (typeof v !== "number" && typeof v !== "string") {
          throw new RuleError("in values must be number or string");
        }
      }

      const fieldValue = record[rule.field];
      return rule.values.some(v => v === fieldValue);
    }
    default:
      const r = rule as unknown;
      const op = (r as Record<string, unknown>).op;
      throw new RuleError(`Unknown operator: ${op}`);
  }
}

export function filter(
  rule: Rule,
  records: Array<Record<string, unknown>>,
): Array<Record<string, unknown>> {
  return records.filter(record => evaluate(rule, record));
}
```
This looks very robust.

One detail: `rule.values.some(v => v === fieldValue)`.
Here `v` is `number | string` (from type).
If I cast `rule.values` to `unknown[]` earlier, I should be consistent?
No, `some` uses the typed version. It's fine. The validation loop ensures runtime correctness.

Final check: `rule.op` in switch.
If `rule` is `{ op: "bad" } as Rule`.
`rule.op` is "bad".
Switch goes to default.
Throws.
Correct.

The solution seems ready.