Skip to content
Angular ng forms 4 min read

Typed Reactive Forms

Before Angular 14, every FormControl was effectively typed as any, which meant form.value returned untyped data and typos in control names slipped past the compiler. Typed reactive forms close that gap: FormControl, FormGroup, FormArray, and FormBuilder now carry precise generic types inferred from your initial values. The result is end-to-end type safety from declaration to value, valueChanges, and get(), with autocompletion in the editor and errors at compile time instead of runtime.

How typing is inferred

When you create a control with an initial value, Angular infers the control type from that value. A FormGroup aggregates the types of its child controls into a strongly-typed value object.

import { Component, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';

@Component({
  selector: 'app-profile',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="save()">
      <input formControlName="name" />
      <input formControlName="age" type="number" />
      <button type="submit">Save</button>
    </form>
  `,
})
export class ProfileComponent {
  form = new FormGroup({
    name: new FormControl('', { nonNullable: true }),
    age: new FormControl(0, { nonNullable: true, validators: [Validators.min(0)] }),
  });

  save() {
    const value = this.form.value; // Partial<{ name: string; age: number }>
    console.log(value.name?.toUpperCase());
  }
}

Here this.form.value has the type Partial<{ name: string; age: number }>. Accessing a control that does not exist, or calling a string method on age, is now a compile error.

Nullable vs non-nullable controls

By default a FormControl is nullable because calling reset() without an argument sets the value back to null. That is why value.name above is string | undefined within the partial. To get a plain non-nullable type, pass nonNullable: true, which also makes reset() restore the initial value instead of null.

const nullable = new FormControl('hello');          // FormControl<string | null>
const strict = new FormControl('hello', { nonNullable: true }); // FormControl<string>

nullable.reset();  // value becomes null
strict.reset();    // value becomes 'hello' (the initial value)
OptionControl typereset() behaviour
defaultFormControl<T | null>resets to null
{ nonNullable: true }FormControl<T>resets to initial value

Prefer nonNullable: true for fields that should never be null. It removes noisy | null unions from your value type and gives reset() more intuitive semantics.

Typed getRawValue, get, and valueChanges

value always returns a Partial because disabled controls are omitted. When you need every control regardless of disabled state, use getRawValue(), which returns the full, non-partial type.

const raw = this.form.getRawValue(); // { name: string; age: number }

The get() method is also typed against the known control names, and valueChanges emits the same partial value type, so RxJS pipelines stay type-safe.

import { map } from 'rxjs';

const ageControl = this.form.get('age'); // FormControl<number> | null
const upper$ = this.form.controls.name.valueChanges.pipe(
  map((name) => name.toUpperCase()), // name is string, not any
);

Nested groups and typed arrays

Nesting works recursively. A child FormGroup contributes a nested object type, and FormArray infers the type of its element controls.

import { FormArray, FormControl, FormGroup } from '@angular/forms';

const form = new FormGroup({
  name: new FormControl('', { nonNullable: true }),
  address: new FormGroup({
    city: new FormControl('', { nonNullable: true }),
    zip: new FormControl('', { nonNullable: true }),
  }),
  tags: new FormArray<FormControl<string>>([]),
});

form.controls.tags.push(new FormControl('angular', { nonNullable: true }));
const city = form.controls.address.controls.city.value; // string

FormRecord for dynamic keys

FormGroup requires a fixed, known set of keys. When control names are dynamic (for example, one toggle per feature flag fetched at runtime), use FormRecord, a homogeneous group where keys are open but every control shares the same type.

import { FormControl, FormRecord } from '@angular/forms';

const flags = new FormRecord<FormControl<boolean>>({});
flags.addControl('darkMode', new FormControl(true, { nonNullable: true }));
flags.addControl('beta', new FormControl(false, { nonNullable: true }));

const value = flags.value; // { [key: string]: boolean }

Opting out with UntypedFormControl

If you are migrating a large codebase, Angular ships UntypedFormGroup, UntypedFormControl, UntypedFormArray, and UntypedFormBuilder that preserve the legacy any behaviour. Use them as a temporary bridge, then replace them incrementally.

import { UntypedFormControl } from '@angular/forms';

const legacy = new UntypedFormControl(''); // value is any

Output:

legacy.value -> any (no compile-time checking)

Treat Untyped* classes as a migration aid only. Leaving them in place permanently discards the entire benefit of typed forms.

Best Practices

  • Always provide an initial value so Angular can infer the control type instead of falling back to any.
  • Use { nonNullable: true } for required fields to drop | null from the value type and get sensible reset() semantics.
  • Reach for getRawValue() when you must read disabled controls; otherwise expect a Partial from value.
  • Use FormRecord for dynamic, same-typed keys and reserve FormGroup for a known, fixed shape.
  • Define a domain interface and let the form shape mirror it, so submission payloads stay aligned with your API contracts.
  • Treat UntypedFormControl and friends as temporary migration scaffolding, not a permanent solution.
Last updated June 14, 2026
Was this helpful?