The exact problem: “post code is required when country is United Kingdom” and “country is required when a post code is entered” are both reasonable-sounding rules, and together they are a cycle — a form that can never settle, and that blames whichever field the reader filled second.
Context and Prerequisites
Detection is covered in building a field dependency graph: a failed topological sort is a cycle. This page is about the shapes that create one and the rewrites that remove it, because a detected cycle still has to be fixed.
Three Shapes That Create a Cycle
Mutual requirement. A requires B, B requires A. Neither can be filled first, so the reader is stuck whichever they choose. The intent is almost always “at least one of A or B”, which is not a cycle at all:
// Wrong: two rules, each naming the other, and no valid starting point.
{ id: 'a-needs-b', reads: ['b'], writes: ['a'] }
{ id: 'b-needs-a', reads: ['a'], writes: ['b'] }
// Right: one rule reading both, writing a form-level requirement.
{ id: 'at-least-one', reads: ['a', 'b'], writes: ['__form'] }
Mutual exclusion. “Only one of these may be filled” written as two clearing rules — filling A clears B, filling B clears A — oscillates. One rule that reads both and reports on both terminates:
{ id: 'exactly-one', reads: ['card', 'account'], writes: ['__form'],
evaluate: (v) => (!!v.card === !!v.account)
? { error: 'Enter either a card or an account, not both' } : {} }
Derivation both ways. Price and quantity each computing the other from a total. Pick one direction, mark the other read-only, and let the reader switch which is derived if they genuinely need to.
Step-by-Step Walkthrough
-
Write the relationship as one rule. If a sentence mentions two fields, it is one rule reading both — not two rules pointing at each other.
-
Write to a group, not to the participants. A rule writing
__formor a fieldset id has no outgoing edge to either field, so it cannot close a loop. -
Choose a direction for derivations. One field is the input; the other is computed and read-only.
-
Attach the message where the action is. For “exactly one”, that is the group. For “B is required when A is X”, that is B, because B is what the reader must fill.
-
Assert acyclicity in a test. Over the real rule set, so a new rule that closes a loop fails review.
-
Prefer structure over rules. A field required only in one mode is often better expressed as a discriminated union than as a conditional rule.
Failure Modes and Edge Cases
1. The cycle only exists for some values
A requires B when A is filled and B requires A when B is filled is a cycle only when both are filled. The graph is value-independent, so it reports the cycle always — which is right: a rule set that can cycle will.
2. Clearing as a side effect
A rule that clears another field’s value creates an edge that is easy to miss, because it looks like housekeeping. Declare it in writes.
3. A three-rule cycle
A affects B, B affects C, C affects A. No pair looks wrong in review; only the sort finds it. This is the strongest argument for the assertion.
4. Visibility rules
Showing and hiding fields creates edges exactly like validation does, and a pair of rules that hide each other is a cycle that manifests as flickering.
5. Requiredness driven by a server response
An async rule that makes a field required, where that field feeds the same request, is a cycle across an await. The graph will not catch it; a bounded retry count will.
Verification Checklist
Related
- Cross-Field Dependency Logic — the graph these rules form
- Building a Field Dependency Graph — detecting the cycle
- Discriminated Unions for Conditional Schemas — expressing the branch structurally
← Cross-Field Dependency Logic
Frequently Asked Questions
Is a cycle always a bug?
In a rule set, yes. A cycle means there is no order in which every rule sees settled inputs, so whichever field the reader touches first determines the outcome — the form is non-deterministic from their point of view. What is usually not a bug is the intent behind it: mutual requirement, mutual exclusion and two-way derivation are all reasonable things to want, and all three have an acyclic expression.
Where should the error for a mutual rule go?
On the group, not duplicated onto both fields. A message on each of two fields is announced twice and counted twice in the summary, while telling the reader nothing more than one message would. Put it on the fieldset that contains them, which is also where aria-describedby belongs for a group-level problem, and let focus land on the first field of the group.
Can a discriminated union replace a conditional requirement?
Often, and it is stronger when it can. ‘Post code is required when delivery is home’ is a rule that has to be maintained; a union where the home variant has a post code field and the collect variant does not is a shape where the requirement cannot be violated. The type narrows with it, so code reading a collect order cannot even refer to a post code. Use a rule when the branch is not structural — a threshold, a date comparison — and structure when it is.