Skip to main content

Logic Engine

Show this section IF (State is WA OR CA) AND (Income < $50,000).
Government forms need complex conditional logic. The Logic Engine evaluates expression trees to determine which fields are visible based on current form values.

Why Expression Trees?

Simple visibility rules like “show X when Y equals Z” don’t scale. Real forms have conditions like:
  • “Show disability section if age > 65 OR has disability”
  • “Show income verification if employment = ‘self-employed’ AND income > $50k”
  • “Hide bank section if (payment method = ‘check’) OR (no bank account)”
We need composable conditions: rules that combine with AND/OR operators into arbitrarily deep trees. This tree evaluates to: (State = WA OR State = CA) AND (Income < 50000)

Data Structures

LogicRule

A single comparison:
type LogicRule = {
  id: string;
  type: "rule";
  fieldId: string;           // Which field to check
  operator: LogicOperator;   // How to compare
  value?: string | number | boolean | array;  // What to compare against
};

LogicGroup

A collection of conditions combined with AND or OR:
type LogicGroup = {
  id: string;
  type: "group";
  operator: "AND" | "OR";
  children: LogicCondition[];  // Rules or nested groups
};

LogicCondition

The recursive union:
type LogicCondition = LogicRule | LogicGroup;

Operators

The engine supports 18 operators:
OperatorDescriptionExample
eqEqualsincome = 50000
neqNot equalsstatus != "denied"
gtGreater thanage > 18
gteGreater than or equalincome >= 30000
ltLess thanhousehold_size < 4
lteLess than or equalage <= 65
containsString/array containsstates contains "WA"
not_containsDoes not containallergies not_contains "peanuts"
starts_withString starts withzip starts_with "98"
ends_withString ends withemail ends_with ".gov"
inValue in liststate in ["WA", "CA", "OR"]
not_inValue not in liststatus not_in ["denied", "cancelled"]
existsHas any valuephone exists
not_existsIs emptymiddle_name not_exists

Age Operators (for date fields)

Special operators that calculate age from a date:
OperatorDescriptionExample
minAgeAt least X years olddob minAge 18 (must be 18+)
maxAgeAt most X years olddob maxAge 65 (must be 65 or under)
underAgeUnder X years olddob underAge 21 (show warning)
overAgeOver X years olddob overAge 62 (show senior info)

Evaluation

The main evaluation function:
// src/lib/logic-engine.ts
export function evaluateLogic(
  logic: LogicCondition | undefined | null,
  formData: FormData
): boolean {
  if (!logic) return true;  // No logic = always visible

  if (logic.type === "rule") {
    return evaluateRule(logic, formData);
  } else if (logic.type === "group") {
    return evaluateGroup(logic, formData);
  }

  return true;
}

Rule Evaluation

function evaluateRule(rule: LogicRule, formData: FormData): boolean {
  const fieldValue = getFieldValue(formData, rule.fieldId);
  return evaluateOperator(rule.operator, fieldValue, rule.value);
}

Group Evaluation

function evaluateGroup(group: LogicGroup, formData: FormData): boolean {
  if (group.children.length === 0) return true;

  if (group.operator === "AND") {
    // All children must be true
    return group.children.every((child) => evaluateLogic(child, formData));
  } else {
    // At least one child must be true
    return group.children.some((child) => evaluateLogic(child, formData));
  }
}

Nested Path Support

Field IDs can reference nested data:
function getFieldValue(formData: FormData, fieldId: string): unknown {
  const parts = fieldId.split(".");
  let value = formData;

  for (const part of parts) {
    if (value === null || value === undefined) return undefined;
    value = value[part];
  }

  return value;
}
This allows rules like address.state = "WA".

Show vs Hide Actions

By default, logic shows fields when conditions are met. But sometimes you want the opposite:
{
  "id": "ssn",
  "type": "text",
  "label": { "en": "SSN" },
  "logic": {
    "type": "rule",
    "fieldId": "is-citizen",
    "operator": "eq",
    "value": "no"
  },
  "logicAction": "hide"  // Hide when NOT a citizen = show only for citizens
}
The logicAction property inverts the logic result:
  • "show" (default): field visible when logic is true
  • "hide": field visible when logic is false

Complete Example

A form section that shows income verification for high-earning self-employed applicants in certain states:
{
  "id": "income-verification",
  "type": "group",
  "label": { "en": "Income Verification" },
  "logic": {
    "id": "root",
    "type": "group",
    "operator": "AND",
    "children": [
      {
        "id": "state-check",
        "type": "group",
        "operator": "OR",
        "children": [
          {
            "id": "wa",
            "type": "rule",
            "fieldId": "address.state",
            "operator": "eq",
            "value": "WA"
          },
          {
            "id": "ca",
            "type": "rule",
            "fieldId": "address.state",
            "operator": "eq",
            "value": "CA"
          }
        ]
      },
      {
        "id": "self-employed",
        "type": "rule",
        "fieldId": "employment-type",
        "operator": "eq",
        "value": "self-employed"
      },
      {
        "id": "high-income",
        "type": "rule",
        "fieldId": "annual-income",
        "operator": "gte",
        "value": 50000
      }
    ]
  },
  "elements": [...]
}
This evaluates as:
(state = "WA" OR state = "CA") AND employment-type = "self-employed" AND annual-income >= 50000

Legacy Visibility (Deprecated)

The old visibility format is still supported for backwards compatibility:
// Old format (deprecated)
{
  "visibility": {
    "when": "has-dependents",
    "is": "yes"
  }
}

// New format (preferred)
{
  "logic": {
    "type": "rule",
    "fieldId": "has-dependents",
    "operator": "eq",
    "value": "yes"
  }
}
The evaluateElementVisibility function handles both:
export function evaluateElementVisibility(element, formData): boolean {
  // Prefer new logic system
  if (element.logic) {
    const result = evaluateLogic(element.logic, formData);
    const action = element.logicAction || "show";
    return action === "show" ? result : !result;
  }

  // Fall back to legacy visibility
  if (element.visibility) {
    return evaluateVisibility(element.visibility, formData);
  }

  // No conditions = always visible
  return true;
}

Helper Functions

Utilities for building logic programmatically:
import {
  createEqualsRule,
  createAndGroup,
  createOrGroup,
} from "@/lib/logic-engine";

// Simple rule
const isAdult = createEqualsRule("is-adult", "yes");

// Combine with AND
const canApply = createAndGroup([
  createEqualsRule("is-adult", "yes"),
  createEqualsRule("is-resident", "yes"),
]);

// Combine with OR
const qualifiesForDiscount = createOrGroup([
  createEqualsRule("is-senior", "yes"),
  createEqualsRule("is-veteran", "yes"),
  createEqualsRule("is-disabled", "yes"),
]);

Form Builder UI

The Logic Builder component (in src/components/form-builder/logic-builder.tsx) provides a visual interface: Users can:
  • Add rules that compare field values
  • Create nested groups with AND/OR
  • See live preview of which fields would show

Performance Considerations

The logic engine re-evaluates on every form value change. For most forms this is instant, but deep nesting could become slow. Optimizations applied:
  1. Short-circuit evaluation — AND stops at first false, OR stops at first true
  2. Direct property access — no recursive tree walking for simple paths
  3. Type coercion caching — number/string conversions memoized per render
Worst case: A form with 100 fields, each with 10-deep nested logic, re-evaluated 60x per second. In practice, forms have 20-50 fields with 2-3 level nesting.

Edge Cases

Empty Values

Empty inputs ("", null, undefined, []) are handled consistently:
function isEmpty(value: unknown): boolean {
  if (value === null || value === undefined) return true;
  if (typeof value === "string" && value.trim() === "") return true;
  if (Array.isArray(value) && value.length === 0) return true;
  return false;
}

Type Coercion

Comparisons handle type mismatches:
// "50000" == 50000 → true (loose equality for eq/neq)
// toNumber("50000") → 50000 (for numeric comparisons)

Invalid Dates

Age operators handle malformed dates:
function calculateAge(dateValue: unknown): number | null {
  // Returns null for invalid dates
  // Logic rules return false when age is null
}