Ch. 4 · Angular

Angular Cross-Field Validation with Reactive Forms

Validate check-in and check-out dates on the shared FormGroup, see why a sibling-reading control validator goes stale, and test each transition.

~6 min readintermediateupdated Oct 19, 2026

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;                    // true
TypeScript

Check-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;
  };
}
TypeScript
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) },
);
TypeScript

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: false
Text

The 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>
HTML
showRangeError(): boolean {
  const touched = this.booking.controls.checkOut.touched || this.booking.controls.checkIn.touched;
  return touched && (this.booking.hasError('dateOrder') || this.booking.hasError('tooLong'));
}
TypeScript

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 required errors present
  • reversed dates: dateOrder present, form invalid
  • fix check-out: error gone, form valid
  • move check-in so the range is too long: tooLong appears 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.

“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.

Keep going

More in Angular

read ✓Angular · hard

Angular Signal Forms Overview

How Signal Forms wrap your model signal in a FieldTree, keep validation in one schema, and map server errors onto fields when you submit.

~6 min readread →
read ✓Angular · mid

Angular Template-Driven vs Reactive Forms

Both form styles build the same FormControl objects. What differs is who creates them and when, which changes timing, typing and testing.

~5 min readread →
esc