Skip to content
    All posts
    Angular

    Angular Reactive Forms at Scale: Typed Controls, Dynamic Fields, and Cross-Field Validation

    A 60-field tax-filing form taught me more about Angular forms than any documentation. What survived the project.

    3 min readby

    The US tax-filing platform I worked on had a form with sixty-plus fields, a dozen conditional sections, and validation rules that depended on other fields' values. It was the hardest UI problem on the project — harder than the payments integration. Here is what I'd do again and what I'd never repeat.

    Type your forms, and let the compiler earn its keep

    Typed reactive forms turn a whole class of runtime bug into a compile error. form.get('filerName') returning AbstractControl | null is how you end up with ?.value sprinkled everywhere and a production error when someone renames a control.

    ts
    interface FilerForm {
      name: FormControl<string>;
      ssn: FormControl<string>;
      filingStatus: FormControl<'single' | 'joint' | 'separate'>;
      spouse: FormGroup<SpouseForm> | null;
    }
    
    private fb = inject(NonNullableFormBuilder);
    
    form = this.fb.group<FilerForm>({
      name: this.fb.control('', Validators.required),
      ssn: this.fb.control('', [Validators.required, ssnValidator]),
      filingStatus: this.fb.control<'single' | 'joint' | 'separate'>('single'),
      spouse: null,
    });

    NonNullableFormBuilder is the detail worth calling out. By default a reset sets controls to null, which is why every value type is T | null. Using the non-nullable builder means a reset returns the initial value instead, and your types stop being littered with nulls that can't actually occur in your flow.

    Dynamic sections: add and remove controls, don't hide them

    The first version of the spouse section used *ngIf on the template and left the controls in the form. That meant hidden required fields blocking submission, and the user staring at a disabled button with no visible error. Every conditional form has this bug at some point.

    ts
    constructor() {
      this.form.controls.filingStatus.valueChanges
        .pipe(takeUntilDestroyed())
        .subscribe((status) => {
          if (status === 'joint') {
            this.form.setControl('spouse', buildSpouseGroup(this.fb));
          } else {
            this.form.removeControl('spouse');
          }
        });
    }

    If a field isn't on screen, it isn't in the form. The form's validity then always matches what the user can see, and "why is submit disabled" stops being a support ticket.

    Cross-field validation belongs on the group

    A rule involving two fields cannot live on either one of them. Put it on their common parent, where both values are available:

    ts
    const dependantAgeRule: ValidatorFn = (group) => {
      const { birthDate, claimedAsDependant } = group.value;
      if (!birthDate || !claimedAsDependant) return null;
      return ageOn(birthDate, TAX_YEAR_END) >= 24
        ? { dependantTooOld: { maxAge: 24 } }
        : null;
    };

    Return a structured error object, not a message string. The validator's job is to say what's wrong; the template's job is to phrase it for a human. Mixing those means your validation logic can't be unit-tested without asserting on English prose, and it can't be translated.

    One error component, everywhere

    Sixty fields means sixty opportunities to phrase an error differently. We built a single component that takes a control and renders the first error from a shared message map, and used it for every field without exception.

    html
    <app-field label="Social Security Number" [control]="form.controls.ssn">
      <input formControlName="ssn" inputmode="numeric" />
    </app-field>

    Consistent spacing, consistent aria-describedby wiring, consistent timing on when errors appear (on blur, not on every keystroke — nothing is more hostile than being told your email is invalid while you're typing the third character).

    What I'd never repeat

    • A fully JSON-driven form engine. We prototyped one. It handled 80% of cases beautifully and made the last 20% nearly impossible, and the last 20% is where the business rules live.
    • Validating on every keystroke. updateOn: 'blur' for most fields, 'change' only where live feedback genuinely helps, like a password strength meter.
    • Storing draft state only in the form. A long form needs to survive a refresh. Serialise form.getRawValue() on a debounce and restore it on load.
    • Disabling the submit button on invalid. Let them submit, then focus the first invalid field and explain what's wrong. A disabled button with no explanation is a dead end.

    That last one is a hill I'll die on. Users don't experience a disabled button as "the form is incomplete". They experience it as "the site is broken".

    AngularFormsTypeScriptValidation

    Keep reading

    PAPulapa Arun Kumar

    Full-stack developer building performant, clean, and user-friendly web & mobile applications. Also written as Arun Kumar Pulapa — same person, surname first.

    Built with

    ReactTypeScriptTailwind CSSVite

    © 2026 Pulapa Arun Kumar. All rights reserved.