In Angular typed forms, how do you choose between an optional FormGroup key, FormRecord and UntypedFormGroup when a form's keys are not fixed?
answer
- are the keys known in advance
- do all controls share a type
- only optional keys can be removed
- FormRecord<FormControl<boolean>> for toggles
basics
~20 sUse an optional key in a typed FormGroup interface when the key is known but sometimes absent, FormRecord when keys are open-ended but every control has the same type, and UntypedFormGroup only when keys are open-ended and types differ.
solid answer
~40 sAngular's typed forms give three models for groups whose keys vary. If the key is **known but sometimes present**, declare it optional in the group's interface, for example `bio?: FormControl<string>`; TypeScript then allows `removeControl('bio')`, while required keys cannot be removed. If the keys are **open-ended but homogeneous**, such as one boolean toggle per notification channel returned by the server, use `FormRecord<FormControl<boolean>>`: any string key is accepted, and every control must have that one type. If the keys are open-ended **and** the controls differ in type, no useful typing is possible and the documented escape hatch is `UntypedFormGroup`, which is simply `FormGroup<any>`. Narrowing helpers like `isFormRecord()` help when code receives an `AbstractControl` and must tell which it has.
code
ts · 15 linesimport {FormControl, FormGroup, FormRecord} from '@angular/forms';
interface NotificationSettings {
digest: FormControl<'daily' | 'weekly'>;
channels: FormRecord<FormControl<boolean>>;
quietHoursStart?: FormControl<string>;
}
const notifications = new FormGroup<NotificationSettings>({
digest: new FormControl<'daily' | 'weekly'>('daily', {nonNullable: true}),
channels: new FormRecord<FormControl<boolean>>({}),
});
notifications.controls.channels.addControl('push', new FormControl(true, {nonNullable: true}));
notifications.removeControl('quietHoursStart'); // allowed: optional keygo deeper
Recall that FormRecord is for groups whose keys are not known in advance and whose controls all share one type.
Explain how optional interface keys gate removeControl() and addControl(), and how FormRecord's single generic describes every control.
Choose the most specific model for each part of a variable form, split mixed records rather than going untyped, and contain any UntypedFormGroup behind a typed boundary.
Set a codebase rule for dynamic forms so that falling back to any is a reviewed exception with a mapping layer, not the default for anything built from metadata.
## Why this choice exists In strictly typed reactive forms (since **Angular 14**), a `FormGroup<T>` is typed by an object whose keys are the control names and whose values are control types. That is ideal for a fixed shape, but many real forms are not fixed. Angular gives you three ways to express variability, and choosing the wrong one either throws away type safety or makes the compiler reject legitimate code. ## Option 1: an optional key on a typed `FormGroup` When you **know the name** of a control that is only sometimes present, declare it optional in an interface: ```ts import {FormControl, FormGroup} from '@angular/forms'; interface ProfileSettings { displayName: FormControl<string>; bio?: FormControl<string>; // only for public profiles } const profile = new FormGroup<ProfileSettings>({ displayName: new FormControl('Ada', {nonNullable: true}), }); profile.addControl('bio', new FormControl('', {nonNullable: true})); profile.removeControl('bio'); // compiles: bio is optional // profile.removeControl('displayName'); // compile error: required key ``` The typing rules are: - `removeControl()` accepts only keys that are **optional** in the interface. - `addControl()` accepts only keys **declared** in the interface, with the matching control type. - The optional modifier carries through to `value` and to `getRawValue()`. This keeps full type safety for every other key while admitting that one field comes and goes. ## Option 2: `FormRecord` for open-ended, homogeneous keys When the key set is **not known ahead of time** but every control has the **same type**, use `FormRecord`. It extends `FormGroup<{[key: string]: TControl}>` and takes one generic: the control type. ```ts import {FormControl, FormRecord} from '@angular/forms'; // channels come from the server: 'email', 'sms', 'push', ... const channels = new FormRecord<FormControl<boolean>>({}); for (const id of ['email', 'push']) { channels.addControl(id, new FormControl(true, {nonNullable: true})); } ``` Any string key is accepted, and any control of type `FormControl<boolean>` can be added. A `FormBuilder` can build one with `fb.record({...})`. What you give up is knowing which keys exist: reading `channels.controls['sms']` may yield `undefined` at runtime. The builder route follows the same nullability rules as elsewhere. `fb.record({email: true})` on a plain `FormBuilder` produces controls typed `FormControl<boolean | null>`, while the non-nullable builder produces `FormControl<boolean>`. Because a `FormRecord` is a `FormGroup` underneath, it also shares the group's value rules: a disabled channel drops out of `value`, and `getRawValue()` returns every channel. Its value type is an index signature, so every lookup is already possibly missing and the `Partial` adds nothing new. ## Option 3: `UntypedFormGroup` as the escape hatch If the keys are open-ended **and** the controls have different types, there is no type a `FormRecord` can express. Angular's guidance is to use `UntypedFormGroup`, which is declared as `FormGroup<any>` and behaves exactly like a pre-v14 group. The same reasoning applies to arrays: a `FormArray` whose elements differ by position needs `UntypedFormArray`. ## The decision table | Key set | Control types | Model | Type safety kept | |---|---|---|---| | Fixed | Any | `FormGroup<T>` | Full | | Fixed, some keys come and go | Any | `FormGroup<T>` with optional keys | Full, plus add/remove checks | | Open-ended | All the same | `FormRecord<TControl>` | Control type only | | Open-ended | Mixed | `UntypedFormGroup` | None | ## Judgement calls in practice 1. **Prefer the most specific model that fits.** Reaching for `UntypedFormGroup` because a form is "dynamic" often discards safety that an optional key or a `FormRecord` would have kept. 2. **Split heterogeneous records.** A metadata-driven form with mixed field types can often become several `FormRecord`s grouped by value type, or a `FormGroup` of known sections each holding a `FormRecord`. 3. **Contain the escape hatch.** If `UntypedFormGroup` is unavoidable, keep it inside one component or service and map its value to a typed object at the boundary, so `any` does not leak through the codebase. 4. **Narrow at runtime when needed.** Code that receives an `AbstractControl` can use `isFormGroup()`, `isFormRecord()`, `isFormArray()` or `isFormControl()` to narrow before reading children. ## What this answer deliberately leaves out How controls are added, removed and rendered at runtime, the events that fire, and how a metadata-driven template iterates them are the job of Angular's dynamic-form APIs. The question here is only which **type** describes the group. The core point is that "dynamic" is not one thing: known-but-optional, open-ended-but-uniform, and open-ended-and-mixed each have their own model, and only the last one needs to give up typing.
- Why can't you call removeControl('displayName') on a typed group where displayName is required?The typed overload of `removeControl()` only accepts keys that are optional in the group's type. Removing a required key would make the declared type lie about the group's contents, so TypeScript rejects it. Declare the key optional if it genuinely comes and goes.
- What do you lose by choosing FormRecord over a typed FormGroup?Knowledge of which keys exist. A `FormRecord` accepts any string key, so the compiler cannot tell you that a given control is present; reading an unknown key can give `undefined` at runtime. You keep the control type, so every value you read or set is still checked.
saying these in an interview costs you the question
- Any form built at runtime must use UntypedFormGroup.
- FormRecord can hold controls of different value types.
- Optional interface keys lose type checking for the whole group.
- UntypedFormGroup is a separate class with different runtime behaviour.
- removeControl() on a typed group accepts any key name.