The exact problem: a form has fourteen conditional rules written as if statements inside change handlers, and nobody can answer two questions about it — which fields does changing the country affect, and can a rule ever trigger itself.

Context and Prerequisites

The reasoning behind modelling dependencies as a graph is in cross-field dependency logic. This page builds the graph: how to declare edges, how to sort them, and how to detect the cycle that no evaluation order can satisfy.

Core Pattern

interface Rule {
  readonly id: string;
  /** Fields this rule READS. These are the incoming edges. */
  readonly reads: readonly string[];
  /** Fields this rule WRITES — validity, requiredness, visibility, a value. */
  readonly writes: readonly string[];
  readonly evaluate: (values: Values) => RuleResult;
}

/** Adjacency built from the rules: field → the fields it can affect. */
export function buildGraph(rules: readonly Rule[]): Map<string, Set<string>> {
  const edges = new Map<string, Set<string>>();
  for (const rule of rules) {
    for (const from of rule.reads) {
      const to = edges.get(from) ?? new Set<string>();
      for (const w of rule.writes) if (w !== from) to.add(w);
      edges.set(from, to);
    }
  }
  return edges;
}

/**
 * Kahn's algorithm. Returns null when a cycle exists — which is a bug in the
 * RULE SET, so it should fail a test rather than be handled at runtime.
 */
export function topologicalOrder(edges: Map<string, Set<string>>): string[] | null {
  const indegree = new Map<string, number>();
  for (const [from, tos] of edges) {
    indegree.set(from, indegree.get(from) ?? 0);
    for (const to of tos) indegree.set(to, (indegree.get(to) ?? 0) + 1);
  }
  const queue = [...indegree].filter(([, d]) => d === 0).map(([n]) => n);
  const order: string[] = [];
  while (queue.length) {
    const node = queue.shift()!;
    order.push(node);
    for (const to of edges.get(node) ?? []) {
      const d = indegree.get(to)! - 1;
      indegree.set(to, d);
      if (d === 0) queue.push(to);
    }
  }
  // Fewer nodes emitted than exist means at least one is stuck in a cycle.
  return order.length === indegree.size ? order : null;
}

/** After a field changes, only its descendants need re-evaluating. */
export function affectedBy(field: string, edges: Map<string, Set<string>>): string[] {
  const seen = new Set<string>();
  const stack = [field];
  while (stack.length) {
    const n = stack.pop()!;
    for (const to of edges.get(n) ?? []) if (!seen.has(to)) { seen.add(to); stack.push(to); }
  }
  return [...seen];
}
Three questions the graph answers and the conditionals cannot What does changing this field affect? A reachability walk from the changed field returns exactly its descendants, so only those rules re-run instead of all of them. In what order should the rules run? A topological sort gives an order in which every rule's inputs are already settled when it evaluates, so one pass suffices instead of iterating to a fixed point. Can a rule trigger itself? A failed topological sort is a cycle, detectable when the rule set is built rather than when a reader finds it — which makes it a failing test instead of a hung tab. what does this affect? walk the descendants of the changed field re-run only those rules in what order? topological sort — inputs are settled before each rule runs one pass, not a fixed point is there a cycle? a failed sort is a cycle, found at build time a failing test, not a hung tab All three come free once rules declare what they read and what they write The declaration is the whole cost: fourteen scattered conditionals become fourteen objects with two arrays each. Sort once when the rule set is registered and cache the order — it changes only when the rules do, never per keystroke. Three kinds of edge, all of which count A validation edge exists where a rule reads one field to judge another. A requiredness edge exists where one field decides whether another must be filled. A visibility edge exists where one field decides whether another is rendered at all. A value edge exists where a rule writes another field's value outright. All four are edges in the same graph, and the last two are the ones most often left undeclared because they do not look like validation. Edge kind Created when Often missed? validation a rule reads A to judge B no requiredness A decides whether B is required sometimes visibility A decides whether B is rendered often value a rule writes B’s value often The bottom two do not look like validation, which is exactly why they are left out — and why the cycle appears later.

Step-by-Step Walkthrough

  1. Make every rule declare reads and writes. This is the entire migration, and it is mechanical.

  2. Build the adjacency once. At registration, not per change.

  3. Sort once and cache. The order depends on the rules, which do not change at runtime.

  4. Assert acyclicity in a test. A cycle is a rule-set bug; discovering it in a browser is a hung tab.

  5. Walk descendants on change. Only the affected subgraph re-evaluates.

  6. Evaluate in sorted order. Every rule’s inputs are settled when it runs, so one pass is enough.

Failure Modes and Edge Cases

1. Under-declared reads

A rule that quietly reads a field it did not declare will not re-run when that field changes. Under-declaring is the dangerous direction; over-declaring only costs extra evaluations.

2. A rule that writes what it reads

Self-edges are excluded above, which is right for the common “normalise this field” rule but hides a real cycle if two rules do it to each other. The sort catches the pair.

3. Async rules in the order

A remote check cannot block the pass. Run the synchronous chain to completion, then dispatch the async ones — and treat their results as arriving later, through the queue rather than through the graph.

4. Dynamic rules

Rules added when a section appears change the graph. Rebuild and re-sort on registration change, and re-assert acyclicity then too.

5. The order is not an announcement order

Sorted order is right for evaluation. For announcing consequences, the reader needs cause before effect, which is the same order — but only if you announce as you evaluate, not after collecting everything.

What changes when, in the graph’s life The rules are registered once, at module load or when a section mounts. The adjacency is built from them at that moment, and the topological order is computed and cached. From then on, every keystroke only walks the cached graph — no rebuilding, no re-sorting. The graph changes again only when the rule set does, at which point both the build and the sort re-run and the acyclicity assertion runs with them. What changes when, in the graph’s life register rules declare reads and writes build adjacency, once, from the rules sort topological order, cached per keystroke walk the cached graph — nothing is rebuilt If any of the first three happens per keystroke, the graph has become more expensive than the conditionals it replaced.

Verification Checklist

Common Pitfalls

  • Under-declaring reads. A rule that quietly reads an undeclared field never re-runs when that field changes, and the bug looks like validation that works for some readers and not others.
  • Leaving visibility rules out. Showing and hiding fields creates edges exactly like validation does, and a pair that hides each other is a cycle that manifests as flickering.
  • Rebuilding per keystroke. The graph depends on the rules, which do not change at runtime. Build and sort once, then only walk.
  • Handling cycles at runtime. Any runtime handling is a silent choice about which rule loses. Assert the sort succeeds in a test instead.
  • Putting async rules in the order. A topological pass that awaits a round trip stops being a pass. Run the synchronous chain, then dispatch.

Related

Cross-Field Dependency Logic

Frequently Asked Questions

What should happen when a cycle is detected?

Fail loudly at build or test time. A cycle means two rules each require the other to have settled first, and no evaluation order satisfies that — so any runtime handling is a choice about which rule silently loses. Assert that topologicalOrder returns non-null in a unit test over the real rule set, and the failure lands on whoever added the rule rather than on a reader whose tab hangs.

Is a graph worth it for four conditional rules?

Probably not for four with no chains. It starts paying when rules affect fields that other rules read — the point at which order matters and the answer to ‘what does changing this affect’ stops being obvious. The migration is mechanical, so a reasonable rule of thumb is to declare reads and writes from the start and only build the graph when the second chained rule appears.

How do async rules fit into a topological order?

They do not, and forcing them to leads to a pass that awaits a network round trip. Run the synchronous chain to completion so the form is consistent, then dispatch the async rules for the affected fields and let their results arrive through the validation queue. The graph tells you which ones to dispatch; it does not have to sequence them.