skip to content

An Angular app still declares its reactive forms with UntypedFormGroup and UntypedFormControl; how would you move it to typed forms safely?

level: seniorimportance: nice to knowfreq 26%

answer

  1. where the Untyped names came from
  2. same runtime, looser types
  3. one form at a time
  4. nonNullable changes reset() behaviour

basics

~20 s

Untyped classes are aliases of the typed ones with any as the value type, so migrate form by form: drop the Untyped prefix, let inference or an interface type it, fix the compile errors, and treat adding nonNullable as a runtime change.

solid answer

~40 s

`UntypedFormGroup` and `UntypedFormControl` are `FormGroup<any>` and `FormControl<any>`: same runtime classes, no type checking. The typed-forms update to Angular 14 rewrote existing forms to these names so apps kept compiling. Migrating is therefore incremental and safe at runtime: pick one form, remove the `Untyped` prefix, let inference type it or declare an interface, and fix what the compiler now reports, usually `null` from `reset()`, `undefined` from disabled children in `value`, and misspelt keys. The step that **does** change behaviour is adding `nonNullable` or switching to `NonNullableFormBuilder`, because `reset()` then restores initial values instead of writing `null`. So audit reset callers and tests before flipping it, and keep genuinely heterogeneous dynamic groups on `UntypedFormGroup` behind a typed mapping.

go deeper

for a junior

Recall that UntypedFormGroup and UntypedFormControl are the typed classes with any as their value type.

for a middle

Explain why removing the prefix is a compile-time change and which compile errors typically appear: null after reset, undefined from disabled children, wrong keys.

for a senior

Plan the migration form by form, keep casts out, treat nonNullable as a reviewed behaviour change with its reset() callers audited, and contain any forms that must stay untyped.

for a principal

Weigh the effort against the forms' lifetime and a possible move to Signal Forms, and set a rule that new code never introduces Untyped classes.

## Where the Untyped names come from Strict typing arrived in **Angular 14**. To avoid breaking every existing form, Angular added untyped versions of the model classes, and the update to v14 included a **typed forms migration** that rewrote existing code to use them. Many long-lived codebases still carry those names. The key fact for planning: the untyped names are not a different implementation. - `UntypedFormControl` is `FormControl<any>`. - `UntypedFormGroup` is `FormGroup<any>`, and `UntypedFormArray` is the `FormArray` equivalent. - At runtime, the `UntypedFormControl`, `UntypedFormGroup` and `UntypedFormArray` constructors **are** the typed classes, so behaviour is identical. - `UntypedFormBuilder` is a thin subclass of `FormBuilder` that delegates to it and only loosens the return types; the two are assignable to each other. That means removing the prefix is a **compile-time** change. It cannot break a running form by itself; it can only surface bugs the `any` was hiding. ## An incremental plan 1. **Inventory.** Search for the `Untyped` names and group usages by form. Forms with a fixed set of fields are the easy wins; forms built from runtime metadata need a design decision. 2. **Migrate one form per change.** Replace `UntypedFormGroup` with `FormGroup` and `UntypedFormControl` with `FormControl`, or `UntypedFormBuilder` with `FormBuilder`. Let inference type simple forms; declare an interface for groups with optional keys. 3. **Fix what the compiler reports.** The usual findings are listed below; each is a real behaviour the old `any` hid. 4. **Decide nullability deliberately.** Adding `{nonNullable: true}` or using `NonNullableFormBuilder` removes `null` from the types, but it also changes what `reset()` does. Treat it as a separate, reviewed step. 5. **Handle dynamic forms last.** Known-but-optional fields become optional interface keys, open-ended uniform fields become `FormRecord`, and only open-ended mixed-type groups stay on `UntypedFormGroup`, ideally behind a function that maps their value to a typed object. ## What the compiler will surface | Finding | Cause | Typical fix | |---|---|---| | `x` is possibly `null` | Plain controls reset to `null` | `nonNullable`, or handle `null` | | `x` is possibly `undefined` on `form.value.x` | Disabled children leave `value` (`Partial`) | `getRawValue()` or explicit handling | | Property does not exist | Misspelt key or wrong nesting, hidden by `any` | Correct the key | | Argument type mismatch on `setValue` | Wrong value shape written to the control | Fix the caller | | `FormControl<null>` rejects writes | Control created as `new FormControl(null)` | Add an explicit generic | The temptation at step 3 is to silence each error with `!` or a cast. That re-creates the untyped situation one line at a time; prefer fixing the cause or choosing `getRawValue()`. ## The one step that changes runtime behaviour Removing the prefix changes nothing at runtime, but **`nonNullable` does**. After it is set, `reset()` restores the control's initial value instead of writing `null`. Code that relied on an empty form after reset (a "clear" button, a post-submit reset, a test asserting `null`) now behaves differently. Before flipping it: - Search for `reset(` on the migrated form and read each caller's intent. - Pass an explicit value to `reset()` where emptiness is the goal. - Update unit tests that asserted `null` after reset. ## Testing the migration Because the prefix removal is type-only, the compiler is the main safety net for steps 2 and 3, and an ordinary test run should stay green. The tests that matter are the ones around step 4: - a test per form that calls `reset()` and asserts the resulting value, written **before** `nonNullable` is added, so the behaviour change is visible in review; - tests for submit handlers that switched from `value` to `getRawValue()`, since the payload now includes disabled fields; - a lint or review rule that rejects new `Untyped` imports, so the migrated area does not regress while the rest is still in progress. ## Risks and trade-offs - **Big-bang rewrites** of hundreds of forms produce huge diffs full of casts. Incremental migration keeps each review small and each fix meaningful. - **Leaving `any` in shared helpers** (a function taking `UntypedFormGroup`) keeps callers untyped. Type the helper generically or narrow its parameter. - **Signal Forms** are a separate API for new forms; migrating reactive forms to typed reactive forms is still worthwhile for code that stays on the reactive model. The summary interviewers look for: the untyped names are aliases, so the migration is a type-level exercise done one form at a time, and the only behavioural switch in it is `nonNullable`, which deserves its own review.

  • Can removing the Untyped prefix alone break a form at runtime?
    No. The untyped classes are the same constructors with `any` as the value type, so the runtime behaviour is identical. Only the compiler's view changes, and the errors it reports point at code that was already fragile. Behaviour changes only when you also add `nonNullable` or switch builders.
  • When is it right to leave a form on UntypedFormGroup?
    When its keys are open-ended and its controls have different value types, for example a group built from server metadata with text, number and boolean fields mixed. Neither optional keys nor `FormRecord` can type it. Keep it contained and map its value to a typed object at the boundary.

saying these in an interview costs you the question

  • UntypedFormGroup has different runtime behaviour, so removing it needs full regression testing.
  • The whole app must switch to typed forms in one release.
  • Adding nonNullable is a type-only change that cannot affect behaviour.
  • Casting every new compile error away completes the migration.
  • Every dynamic form can be typed with a FormRecord.