Most validators look at one value: is it present, is it an email, is it at least 8 characters. Cross-field rules are different. “Check-out must be after check-in”, “confirm password must match password” and “the discount can’t exceed the subtotal” all depend on two controls at once. Where you attach that rule decides whether it stays correct as the user edits, and that’s the heart of the interview question.
The short answer is: put it on the FormGroup that contains both controls. The longer answer, which is what interviewers want, explains why the obvious alternative breaks, what the error should look like, and how to show it to the user.
Why the rule lives on the group
Angular validates bottom-up. When a control’s value changes, Angular runs that control’s validators, then walks up and runs its parent group’s validators, then the grandparent’s, and so on. A validator on the group therefore runs whenever any child changes. A validator on a single control runs only when that control changes.
That difference is the whole story. Here’s the tempting version, a control-level validator that reaches sideways to its sibling:
const notBeforeCheckIn: ValidatorFn = (control) => {
const checkIn = control.parent?.get('checkIn')?.value;
return checkIn && control.value && control.value <= checkIn ? { dateOrder: true } : null;
};
const form = new FormGroup({
checkIn: new FormControl('2026-11-10'),
checkOut: new FormControl('2026-11-12', notBeforeCheckIn),
});
form.controls.checkIn.setValue('2026-11-15');
form.controls.checkOut.errors; // null
form.valid; // trueCheck-in is now three days after check-out, and the form reports itself valid. Changing checkIn re-ran checkIn’s validators and the group’s, but nothing told checkOut to re-check. Teams that hit this usually patch it with a valueChanges subscription that calls checkOut.updateValueAndValidity(), which works until someone adds a third related field. The group-level validator needs no patching, because the group is already the thing that sees every change.
A worked example: a hotel booking range
The booking form has two date inputs and a business rule: check-out must be after check-in, and stays are capped at 14 nights. Write the validator as a factory so it can be reused with different control names and limits:
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function dateRange(startKey: string, endKey: string, maxNights: number): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const start = group.get(startKey)?.value as string | null;
const end = group.get(endKey)?.value as string | null;
if (!start || !end) return null; // `required` reports emptiness
const nights = (Date.parse(end) - Date.parse(start)) / 86_400_000;
if (nights <= 0) return { dateOrder: { start, end } };
if (nights > maxNights) return { tooLong: { nights, maxNights } };
return null;
};
}readonly booking = new FormGroup(
{
checkIn: new FormControl('', { nonNullable: true, validators: Validators.required }),
checkOut: new FormControl('', { nonNullable: true, validators: Validators.required }),
},
{ validators: dateRange('checkIn', 'checkOut', 14) },
);Every decision in there has a reason.
It returns null for empty values. “Enter a check-out date” and “Check-out must be after check-in” are different messages for different problems. If the range validator also complained about empty fields, you’d show two errors for one mistake. Each validator owns one rule.
The errors carry data. { tooLong: { nights: 24, maxNights: 14 } } lets the template say “Stays are limited to 14 nights (you picked 24)” without recomputing anything. A bare true throws that information away.
It reads controls with group.get(). group.value leaves out disabled controls. If some flow disables check-out, group.value.checkOut is undefined and the validator silently passes. group.get('checkOut').value (or getRawValue()) still returns the value.
It parses YYYY-MM-DD with Date.parse. Date-only ISO strings are parsed as UTC midnight, so the subtraction gives whole days even across a daylight saving change. Parse them as local dates and you can get 23 or 25 hours for one night.
It doesn’t call setErrors() on a child. Writing errors into checkOut from the group validator feels convenient for the template, but the next time checkOut re-validates, its own validators overwrite whatever you put there, and its required error can be wiped by yours. The group owns the cross-field error; the template decides where to display it.
What the form reports at each step
Running that exact form against Angular 20’s @angular/forms and logging booking.errors, checkOut.errors and booking.valid after each change:
empty null | checkOut: {"required":true} | valid: false
out before in {"dateOrder":{"start":"2026-11-10","end":"2026-11-08"}} | checkOut: null | valid: false
fixed null | checkOut: null | valid: true
check-in moved 24 nights back {"tooLong":{"nights":24,"maxNights":14}} | checkOut: null | valid: false
after reset null | checkOut: {"required":true} | valid: falseThe fourth line is the one the sibling-reading validator got wrong: only check-in changed, and the group still caught it. Note also that checkOut.errors stays null throughout. The error is on the group, so booking.hasError('dateOrder') is the check, not booking.controls.checkOut.hasError(...). Confusing those two levels is the most common reason “my validator works but the message never shows”.
Showing the error next to the right field
Users don’t see groups; they see fields. Show the range error under check-out, and connect it for screen readers:
<form [formGroup]="booking" (ngSubmit)="submit()">
<label for="check-in">Check-in</label>
<input id="check-in" type="date" formControlName="checkIn" />
<label for="check-out">Check-out</label>
<input
id="check-out"
type="date"
formControlName="checkOut"
[attr.aria-invalid]="showRangeError() ? 'true' : null"
[attr.aria-describedby]="showRangeError() ? 'range-error' : null"
/>
@if (showRangeError()) {
<p id="range-error">
@if (booking.hasError('dateOrder')) {
Check-out must be after check-in.
} @else {
Stays are limited to {{ booking.getError('tooLong').maxNights }} nights
(you picked {{ booking.getError('tooLong').nights }}).
}
</p>
}
<button type="submit">Check availability</button>
</form>showRangeError(): boolean {
const touched = this.booking.controls.checkOut.touched || this.booking.controls.checkIn.touched;
return touched && (this.booking.hasError('dateOrder') || this.booking.hasError('tooLong'));
}Wait for a touch before showing it, so the user isn’t told off while still picking dates. In submit(), call this.booking.markAllAsTouched() when the form is invalid so every pending message appears at once. If you use Angular Material, a custom ErrorStateMatcher that also checks the parent group’s error gives you the red outline on the right field.
Note
Client-side validation is feedback, not enforcement. The booking API still has to reject a reversed range, because anyone can send a request without your form.
Testing transitions, not snapshots
A single “invalid input gives an error” test misses the bugs above. Test the sequence. Plain FormGroup instances work without TestBed, so these tests are fast:
- both empty: no group error, both
requirederrors present - reversed dates:
dateOrderpresent, form invalid - fix check-out: error gone, form valid
- move check-in so the range is too long:
tooLongappears even though check-out didn’t change - reset: values and errors back to the initial state
The fourth case is the regression test for the stale-sibling bug. If someone later “simplifies” the validator onto the check-out control, that test fails.
Interview exercise: three related fields
“A refund form has orderTotal (read-only, disabled), refundAmount and restockingFee. The rule is refundAmount + restockingFee <= orderTotal. A junior developer put a validator on refundAmount that reads the other two through control.parent. Users report they can submit invalid refunds. What’s going on, and how do you fix it?”
Answer. There are two bugs. The validator only runs when refundAmount changes, so editing restockingFee afterwards can push the total over the limit without any re-validation; the form stays valid. And if the validator reads parent.value.orderTotal, it gets undefined because disabled controls are excluded from value, which makes the comparison against NaN and it never fails. I’d move the rule to the group as a validator factory that reads all three with group.get(...).value, returns { refundExceedsTotal: { max, requested } } when violated, and ignores empty values so required handles those. In the template I’d show the message under refundAmount when either editable field has been touched. The tests would cover editing each of the two editable fields separately to push the sum over the limit, and confirm the server rejects the same payload.