The validator was supposed to enforce one obvious rule: the end date can't come before the start date. It worked when I wrote it. It broke the week someone made the date fields conditional — render the end date only after a start date is picked — and suddenly the form would throw, intermittently, with a null read deep inside a validator nobody had touched in a month.
The rule was attached to the end control. To check itself, it reached upward — control.parent?.get('start') — to read the value of a field it didn't own. Most of the time the parent and the sibling were both there and it was fine. When the end control was constructed before it was attached to the group, parent was null. When the start field hadn't rendered yet, get('start') came back null. The validator was asking a question it had no standing to ask, and the answer depended on construction order it didn't control.
A field validating itself against another field it has to go find is a rule living at the wrong altitude.
A validator that reads a sibling is reaching outside its scope
A control validator's scope is its own value. The instant a rule needs two values to make a decision, it has outgrown the control — and bolting it onto one of the two participants creates three problems at once.
The first is timing. The validator runs as part of the control's lifecycle, which is not guaranteed to be after its sibling exists. Conditional fields, form arrays, a wizard step that builds controls as you go — all of them can run a control's validators while the other half of the rule is still null.
The second is placement. The error ends up on end, so that's where the UI shows it. But "end before start" isn't a fact about the end date alone; it's a fact about the pair. Put the message under one field and you've told the user the wrong field is wrong.
The third is the one that bites latest: the rule is invisible from the top. Someone reading the FormGroup sees two normal date controls and no indication that they're entangled. The constraint that's most important to the form is the one least findable in it.
Put the rule on the thing that owns both fields
The group owns both controls, so the group is where a rule about both controls belongs. A FormGroup-level validator receives the group, can read both children directly with no parent traversal, and sets its error on the group — one place, regardless of how the children are built or ordered. Typing the form makes the whole thing honest: the validator is handed a value whose shape is known at compile time, so "reach for a control that might not be there" becomes much harder to write.
const range = new FormGroup({
start: new FormControl<Date | null>(null, Validators.required),
end: new FormControl<Date | null>(null, Validators.required),
}, { validators: endAfterStart });
function endAfterStart(group: AbstractControl): ValidationErrors | null {
const { start, end } = group.value as { start: Date | null; end: Date | null };
if (!start || !end) return null; // nothing to compare yet — not an error
return end < start ? { endBeforeStart: true } : null;
}
The validator no longer hunts for a sibling; it's given both values as a typed object. The template reads the error off the group, so the message lands on the pair, not on one date:
<fieldset [formGroup]="range">
<input type="date" formControlName="start" />
<input type="date" formControlName="end" />
@if (range.errors?.['endBeforeStart']) {
<p class="error">The end date must be on or after the start date.</p>
}
</fieldset>
The output is the behavior I wanted from the start. Build the controls in any order, render the end field conditionally, rebuild the group in a wizard step — the rule fires only when both values exist, never throws on a half-built form, and shows its message once, attached to the range rather than blaming a single field. The constraint is now visible at the top of the form definition, where the next developer will actually find it.
You can step through typed forms and cross-field validation — the group-level rule, the error placement, a multi-step wizard that builds controls as it goes — on the forms demo, source alongside.
A Rule's Owner Is Whoever Can See Everything It Needs
The reflex that produced the bug was reasonable: the rule is "about" the end date, so it went on the end date. But a validation rule belongs to whatever scope can see all the values it depends on, and for a cross-field rule that scope is the group, never one of the fields. Decide ownership by reach, not by which field feels like the subject. Get that right and the rule fires at the correct time, blames the correct thing, and stays visible to the next person — three bugs you simply never write, because the rule was standing in the right place to begin with.


