Skip to the solution
MediumFrontend9 min read

How do you handle dynamic validation where one form field's validator depends on another field's value?

808 views

The problem

Question presented to candidate: "On our registration form, the phone number is required only if the user picks 'contact me by phone', and the confirm-password field must match the password. How do you implement validation where one field's rules depend on another field's value?"

What a strong answer should cover:

  • Two different problems: a cross-field rule (confirm must equal password) and a conditional rule (phone required only when contact method is phone).
  • Cross-field rules go on the parent FormGroup (new FormGroup({...}, { validators: matchFields(...) })), because the group re-validates whenever any child changes; the error lives on the group (form.errors).
  • A validator on the child that peeks at control.parent goes stale: it does not re-run when the other field changes.
  • Conditional rules: subscribe to the driver field's valueChanges and call addValidators / removeValidators (or setValidators), then updateValueAndValidity(), which is required or the status stays stale.
  • Keep a reference to the validator function so removeValidators can find it; Validators.minLength(3) creates a new function every call.
  • Clean up the subscription (takeUntilDestroyed), and mention the alternative of a group validator that encodes the condition, and Signal Forms' validate with valueOf / applyWhen.

Clarifying questions expected:

  • "Should the mismatch error be shown on the confirm field, or as a form-level message?"
  • "When the phone becomes optional again, should an already typed number be kept or cleared?"

Code / implementation expected: Yes β€” a group-level validator plus a validator swapped in response to another control's value.

  • forms
  • validation

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

9 min read Β· 11 sections

Read the code

AngularGroup-level password match plus a phone field that becomes required only when contact method is phone
import { Component, DestroyRef, inject, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, ValidatorFn, Validators } from "@angular/forms";

// Cross-field rule: lives on the GROUP, because it needs two children.
export const matchFields = (a: string, b: string): ValidatorFn => (group: AbstractControl): ValidationErrors | null => {
  const first = group.get(a)?.value;
  const second = group.get(b)?.value;
  return first && second && first !== second ? { mismatch: { fields: [a, b] } } : null;
};

@Component({
  selector: "app-root",
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form">
      <select id="contact" formControlName="contactBy">
        <option value="email">email</option>
        <option value="phone">phone</option>
      </select>
      <input id="phone" formControlName="phone" placeholder="phone" />
      <input id="password" formControlName="password" placeholder="password" />
      <input id="confirm" formControlName="confirm" placeholder="confirm" />
    </form>
    <p id="out">{{ out() }}</p>
  `,
})
export class AppComponent {
  private destroyRef = inject(DestroyRef);
  private phoneRequired = Validators.required; // keep the same reference so removeValidators can find it

  form = new FormGroup(
    {
      contactBy: new FormControl<"email" | "phone">("email", { nonNullable: true }),
      phone: new FormControl("", { nonNullable: true }),
      password: new FormControl("", { nonNullable: true }),
      confirm: new FormControl("", { nonNullable: true }),
    },
    { validators: matchFields("password", "confirm") }
  );
  out = signal("");

  constructor() {
    const phone = this.form.controls.phone;
    // Dependent rule: when contactBy changes, swap the validators on phone.
    this.form.controls.contactBy.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((how) => {
      if (how === "phone") phone.addValidators(this.phoneRequired);
      else phone.removeValidators(this.phoneRequired);
      phone.updateValueAndValidity(); // validators only run on the next value change unless you ask
    });
    this.form.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.snapshot());
    this.snapshot();
  }

  snapshot() {
    const f = this.form;
    this.out.set(
      "form=" + f.status + " groupErrors=" + JSON.stringify(f.errors) + " phone=" + f.controls.phone.status + " " + JSON.stringify(f.controls.phone.errors)
    );
  }
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 32 of 121 in Angular

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Angular questions
Up nextMedium

How do you mock services in Angular unit tests using Jasmine/Jest spies?

Continue