Since Angular 14, reactive forms are strictly typed — but many codebases still carry UntypedFormGroup from the automatic migration, and teams that do adopt types are surprised that form.value.email is string | undefined, that reset() sets fields to null, and that disabled controls vanish from value.

Each surprise is a deliberate part of the type model, and each has a precise fix. This page walks through typing a real form — nullable versus non-nullable controls, value versus getRawValue(), dynamic keys with FormRecord, and typed validators — as groundwork for the adapter patterns in Angular reactive forms adapters.


Context and prerequisites

The type rules Angular applies:

  • new FormControl("x") has type FormControl<string | null>, because reset() with no argument sets the value to null.
  • new FormControl("x", { nonNullable: true }) has type FormControl<string>, and reset() restores "x" — the initial value, not null.
  • FormGroup.value is Partial<…>: every key is optional, because disabled controls are omitted from value.
  • FormGroup.getRawValue() includes disabled controls and is fully typed without Partial.
  • FormBuilder.nonNullable (or NonNullableFormBuilder) builds every control non-nullable, which is what most forms actually want.

Once you know these four rules, the types stop being noise and start catching real bugs — a submit handler that forgets a disabled field, a reset that blanks required values.

What each API returns for a form with one disabled field For a form with name, email and a disabled accountId, form.value is typed Partial with all keys optional and at runtime omits accountId. getRawValue is fully typed and includes accountId. A nullable control after reset with no argument holds null. A non-nullable control after reset holds its initial value. Expression Type Runtime value form.value Partial<{ name; email; accountId }> accountId missing (disabled) form.getRawValue() { name; email; accountId } includes accountId control.reset() (nullable) string | null null control.reset() (nonNullable) string the initial value

The core pattern: a non-nullable builder, explicit raw values, typed validators

import { Component, inject } from "@angular/core";
import {
  AbstractControl, FormRecord, NonNullableFormBuilder, ReactiveFormsModule,
  ValidationErrors, ValidatorFn, Validators, FormControl,
} from "@angular/forms";

// A typed validator: declares the value type it accepts and the error shape it returns.
export function minAgeValidator(min: number): ValidatorFn {
  return (control: AbstractControl<number | null>): ValidationErrors | null => {
    const v = control.value;
    if (v === null) return null;                     // "required" is a separate validator
    return v < min ? { minAge: { required: min, actual: v } } : null;
  };
}

@Component({
  selector: "app-account-form",
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: "./account-form.html",
})
export class AccountFormComponent {
  private fb = inject(NonNullableFormBuilder);      // every control non-nullable

  form = this.fb.group({
    accountId: this.fb.control({ value: "ACC-812", disabled: true }),   // shown, not editable
    name: this.fb.control("", [Validators.required, Validators.minLength(2)]),
    email: this.fb.control("", [Validators.required, Validators.email]),
    // Genuinely optional numeric field: opt back INTO null explicitly.
    age: new FormControl<number | null>(null, [minAgeValidator(16)]),
    // Dynamic keys (feature flags, custom attributes): FormRecord keeps value typing.
    preferences: new FormRecord<FormControl<boolean>>({}),
  });

  addPreference(key: string) {
    this.form.controls.preferences.addControl(key, this.fb.control(false));
  }

  submit() {
    if (this.form.invalid) { this.form.markAllAsTouched(); return; }
    // getRawValue: includes disabled accountId and is fully typed (no Partial).
    const payload = this.form.getRawValue();
    //    ^? { accountId: string; name: string; email: string; age: number | null; preferences: Record<string, boolean> }
    this.save(payload);
  }

  resetToLoaded() {
    this.form.reset();   // non-nullable controls return to their INITIAL values, not null
  }

  private save(_p: ReturnType<typeof this.form.getRawValue>) { /* … */ }
}

Step-by-step walkthrough

  1. Inject NonNullableFormBuilder. Most fields should reset to their initial value; making non-nullability the default removes | null from nearly every type.
  2. Opt specific fields into null. A truly optional number or date uses new FormControl<T | null>(null), so “empty” is representable and the type says so.
  3. Use getRawValue() for payloads. It includes disabled controls and has no Partial; value is for cases where omitting disabled fields is intended, which ties into skipping validation for disabled and hidden fields.
  4. Use FormRecord for dynamic keys. Unlike FormGroup, it allows adding and removing controls by arbitrary string keys while keeping each control’s type.
  5. Type validators by their input. AbstractControl<number | null> in the signature documents what the validator expects and catches misuse at the call site.
  6. Access controls through form.controls. form.controls.email is typed; form.get("email") returns AbstractControl | null and loses the type.

Why value is Partial, and why that is useful

It feels like a type-system annoyance that form.value.email may be undefined when every control exists. The reason is runtime behaviour: Angular excludes disabled controls from a group’s value, and whether a control is disabled is a runtime fact the compiler cannot know. The Partial type is therefore telling the truth. The practical consequence is healthy: code that builds a request body from value must handle the absent case, which is exactly the bug — a disabled field silently missing from the payload — that the type exists to prevent. Choose deliberately between value (participating fields only) and getRawValue() (everything).

Migrating an untyped form to typed Start by replacing UntypedFormBuilder with NonNullableFormBuilder, which types every control from its initial value. Fix compile errors where code assumed values could be null or undefined. Replace form.get with form.controls access to keep types. Finally switch submit payloads to getRawValue where disabled fields must be included, and add explicit nullable controls for genuinely optional fields. Swap the builder UntypedFormBuilder → NonNullableFormBuilder Types flow from initial values. Fix the compile errors Places that assumed null/undefined. Each error is a real assumption made visible. form.get → form.controls Keep control types. String paths lose typing. Payloads and optional fields getRawValue(); FormControl<T | null> where needed. Disabled fields included on purpose; optional fields explicit.

Typing the template side

Types in the component class do not automatically reach templates in older setups. Enable strictTemplates in the Angular compiler options so bindings such as [formControl]="form.controls.email" and expressions reading form.controls.age.value are type-checked. With it on, a template that treats a nullable age as a number, or references a control that was renamed, fails the build instead of rendering null at runtime. Pair it with a small typed helper for error messages — errorText(form.controls.email) returning a string or null — so templates never index into the untyped errors record directly.


Failure modes and edge cases

1. reset() blanks required fields

With nullable controls, reset() sets values to null, making required fields invalid and, in templates, displaying empty. Non-nullable controls reset to their initial value. If “initial” should be the last saved value, rebuild or reset(savedValues) — the same rebasing logic as resetting the dirty baseline after a successful save.

2. Numbers from text inputs

<input type="number" formControlName="age"> gives a number through Angular’s number value accessor (and null when empty). A plain text input gives a string even if the control is typed number; the type is a promise the template must keep. Use the right input type or a custom value accessor.

3. patchValue versus setValue

setValue requires every key (typed as the full value); patchValue accepts Partial. Loading a record with missing optional keys needs patchValue, or defaults applied first.

4. Errors typed as any

control.errors is ValidationErrors | null, a string-keyed record of any. Wrap access in a helper that maps known keys (required, minlength, minAge) to messages, so templates never read raw error objects.

5. Signals interop

Typed controls expose typed valueChanges, which convert cleanly to signals with toSignal — the basis of bridging Angular signals and reactive forms.

Choosing a control type Use a non-nullable control for most fields, such as names and emails, which reset to their initial value. Use an explicitly nullable control for genuinely optional values where empty must be representable, such as an optional age or date. Use a FormRecord for groups whose keys are only known at runtime, such as user-defined preferences. Non-nullable Names, emails, most fields. reset() → initial value. FormControl<T | null> Optional number or date. Empty is representable. FormRecord Keys known only at runtime. Each control stays typed.

Verification checklist


Frequently Asked Questions

Why did the migration give me UntypedFormGroup everywhere?

The automatic migration preserved existing behaviour by switching to the untyped aliases, so nothing broke at upgrade time. Replacing them with typed constructors, form by form, is the intended follow-up.

Is nonNullable the same as Validators.required?

No. nonNullable affects the type and what reset() restores; it does not make the field required. An empty string is a valid string. Use Validators.required for “must be filled in”.

Can I infer the form type from a Zod schema instead?

You can derive a TypeScript type with z.infer and assert that getRawValue() matches it with a satisfies check. The two stay in sync if you also validate the raw value with the schema before submitting, which catches template–type mismatches at runtime.


Related

← Angular Reactive Forms Adapters