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];
}
Step-by-Step Walkthrough
-
Make every rule declare
readsandwrites. This is the entire migration, and it is mechanical. -
Build the adjacency once. At registration, not per change.
-
Sort once and cache. The order depends on the rules, which do not change at runtime.
-
Assert acyclicity in a test. A cycle is a rule-set bug; discovering it in a browser is a hung tab.
-
Walk descendants on change. Only the affected subgraph re-evaluates.
-
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.
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 — why the graph shape matters
- Conditional Required Fields Without Cycles — the rule shape that most often creates one
- Queueing Async Validators in Order — where the async rules go
← 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.