← run

ts-10-rule-engine

1.000
17/17 tests· architecture

Proposed solution

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

function validateRule(rule: unknown): asserts rule is Rule {
  if (!rule || typeof rule !== 'object') {
    throw new RuleError('Invalid rule: not an object');
  }

  const ruleObj = rule as Record<string, unknown>;
  
  if (!('op' in ruleObj)) {
    throw new RuleError('Invalid rule: missing op');
  }
  
  const op = ruleObj.op;
  if (typeof op !== 'string') {
    throw new RuleError('Invalid rule: op must be a string');
  }

  switch (op) {
    case 'and':
    case 'or': {
      if (!('rules' in ruleObj) || !Array.isArray(ruleObj.rules)) {
        throw new RuleError(`Invalid rule: ${op} must have an array of rules`);
      }
      for (const subRule of ruleObj.rules) {
        validateRule(subRule);
      }
      break;
    }
    case 'not': {
      if (!('rule' in ruleObj) || typeof ruleObj.rule !== 'object') {
        throw new RuleError('Invalid rule: not must have a child rule');
      }
      validateRule(ruleObj.rule);
      break;
    }
    case 'eq':
    case 'ne':
    case 'lt':
    case 'lte':
    case 'gt':
    case 'gte': {
      if (!('field' in ruleObj) || typeof ruleObj.field !== 'string') {
        throw new RuleError(`Invalid rule: ${op} must have a string field`);
      }
      if (!('value' in ruleObj) || (typeof ruleObj.value !== 'number' && typeof ruleObj.value !== 'string')) {
        throw new RuleError(`Invalid rule: ${op} must have a number or string value`);
      }
      break;
    }
    case 'in': {
      if (!('field' in ruleObj) || typeof ruleObj.field !== 'string') {
        throw new RuleError('Invalid rule: in must have a string field');
      }
      if (!('values' in ruleObj) || !Array.isArray(ruleObj.values)) {
        throw new RuleError('Invalid rule: in must have an array of values');
      }
      for (const value of ruleObj.values) {
        if (typeof value !== 'number' && typeof value !== 'string') {
          throw new RuleError('Invalid rule: in values must be numbers or strings');
        }
      }
      break;
    }
    default:
      throw new RuleError(`Invalid rule: unknown operation ${op}`);
  }
}

export function evaluate(rule: Rule, record: Record<string, unknown>): boolean {
  validateRule(rule);
  
  switch (rule.op) {
    case 'and':
      return rule.rules.every(subRule => evaluate(subRule, record));
    
    case 'or':
      return rule.rules.some(subRule => evaluate(subRule, record));
    
    case 'not':
      return !evaluate(rule.rule, record);
    
    case 'eq':
      return record[rule.field] === rule.value;
    
    case 'ne':
      return record[rule.field] !== rule.value;
    
    case 'lt':
      const ltFieldValue = record[rule.field];
      if (typeof ltFieldValue === 'number' && typeof rule.value === 'number') {
        return ltFieldValue < rule.value;
      }
      if (typeof ltFieldValue === 'string' && typeof rule.value === 'string') {
        return ltFieldValue < rule.value;
      }
      return false;
    
    case 'lte':
      const lteFieldValue = record[rule.field];
      if (typeof lteFieldValue === 'number' && typeof rule.value === 'number') {
        return lteFieldValue <= rule.value;
      }
      if (typeof lteFieldValue === 'string' && typeof rule.value === 'string') {
        return lteFieldValue <= rule.value;
      }
      return false;
    
    case 'gt':
      const gtFieldValue = record[rule.field];
      if (typeof gtFieldValue === 'number' && typeof rule.value === 'number') {
        return gtFieldValue > rule.value;
      }
      if (typeof gtFieldValue === 'string' && typeof rule.value === 'string') {
        return gtFieldValue > rule.value;
      }
      return false;
    
    case 'gte':
      const gteFieldValue = record[rule.field];
      if (typeof gteFieldValue === 'number' && typeof rule.value === 'number') {
        return gteFieldValue >= rule.value;
      }
      if (typeof gteFieldValue === 'string' && typeof rule.value === 'string') {
        return gteFieldValue >= rule.value;
      }
      return false;
    
    case 'in':
      const inFieldValue = record[rule.field];
      return rule.values.some(value => value === inFieldValue);
    
    default:
      throw new RuleError(`Unknown operation: ${(rule as any).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.488353
  type: 'test'
  ...
# Subtest: eq does not coerce types (number vs string)
ok 2 - eq does not coerce types (number vs string)
  ---
  duration_ms: 0.058471
  type: 'test'
  ...
# Subtest: numeric lt / lte / gt / gte
ok 3 - numeric lt / lte / gt / gte
  ---
  duration_ms: 0.05992
  type: 'test'
  ...
# Subtest: lexicographic string ordering
ok 4 - lexicographic string ordering
  ---
  duration_ms: 0.050821
  type: 'test'
  ...
# Subtest: ordering with missing field is false
ok 5 - ordering with missing field is false
  ---
  duration_ms: 0.0463
  type: 'test'
  ...
# Subtest: ordering with type mismatch is false (no throw)
ok 6 - ordering with type mismatch is false (no throw)
  ---
  duration_ms: 0.03861
  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.042791
  type: 'test'
  ...
# Subtest: in membership
ok 8 - in membership
  ---
  duration_ms: 0.06858
  type: 'test'
  ...
# Subtest: not negates its child
ok 9 - not negates its child
  ---
  duration_ms: 0.048681
  type: 'test'
  ...
# Subtest: empty and is true, empty or is false
ok 10 - empty and is true, empty or is false
  ---
  duration_ms: 0.07605
  type: 'test'
  ...
# Subtest: and / or short-circuit semantics over multiple rules
ok 11 - and / or short-circuit semantics over multiple rules
  ---
  duration_ms: 0.085081
  type: 'test'
  ...
# Subtest: deeply nested and / or / not
ok 12 - deeply nested and / or / not
  ---
  duration_ms: 0.06089
  type: 'test'
  ...
# Subtest: filter keeps matching records in original order
ok 13 - filter keeps matching records in original order
  ---
  duration_ms: 0.339462
  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.055181
  type: 'test'
  ...
# Subtest: RuleError thrown for an unknown op
ok 15 - RuleError thrown for an unknown op
  ---
  duration_ms: 0.181051
  type: 'test'
  ...
# Subtest: RuleError thrown for malformed and / not / comparison nodes
ok 16 - RuleError thrown for malformed and / not / comparison nodes
  ---
  duration_ms: 0.10565
  type: 'test'
  ...
# Subtest: filter propagates RuleError from an invalid rule
ok 17 - filter propagates RuleError from an invalid rule
  ---
  duration_ms: 0.038031
  type: 'test'
  ...
1..17
# tests 17
# suites 0
# pass 17
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 81.611489
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.