How would you build an Angular reactive form from server-supplied field metadata, including fields that appear only when another field has a certain value?
answer
- configuration in, controls out
- one builder function, one renderer
- @switch on field type
- addControl/removeControl or disable()
basics
~20 sMap each metadata entry to a FormControl with validators derived from it, add them to a group, and render with @for plus @switch on the field type using [formControlName]. Handle conditional fields with addControl/removeControl, or disable() to keep their values.
solid answer
~40 sTreat the metadata as input to two pure pieces. A **builder** turns each field description (`key`, `type`, `required`, `options`, `initial`) into a `FormControl` with validators chosen from the description, and adds it to a group with `addControl(key, control, {emitEvent: false})`. A **renderer** loops over the same metadata with `@for (field of fields; track field.key)` and uses `@switch (field.type)` to pick an input, binding each with `[formControlName]="field.key"` inside `[formGroup]`. For a field that depends on another, subscribe to the controlling field's `valueChanges` and call `addControl()` or `removeControl()` on the group, or keep the control and toggle `disable()`/`enable()`: a disabled control keeps its value but drops out of `value` and validation. Keep the metadata and the controls in step, since a template that binds a key with no matching control throws.
code
ts · 20 linesimport {DestroyRef, inject} from '@angular/core';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {FormControl, FormRecord, Validators} from '@angular/forms';
type FieldValue = string | number | null;
export function wireCompanyName(form: FormRecord<FormControl<FieldValue>>): void {
const destroyRef = inject(DestroyRef);
const companyName = new FormControl<FieldValue>(null, Validators.required);
form.controls['customerType'].valueChanges
.pipe(takeUntilDestroyed(destroyRef))
.subscribe((type) => {
if (type === 'business') {
form.addControl('companyName', companyName);
} else {
form.removeControl('companyName');
}
});
}go deeper
Recall that controls can be created in a loop from configuration, added with addControl, and bound with [formControlName] using an expression.
Explain the split into a pure builder and a generic renderer, @switch on field type, and the difference between removing and disabling a conditional control.
Keep visibility rules in one place, avoid full rebuilds that discard state, validate incoming metadata, and keep the server authoritative for the submitted payload.
Decide how far a metadata schema should go before it becomes a second UI framework, and version the schema so client and server stay compatible.
## The shape of the problem In a metadata-driven form, the server sends a description of fields instead of the client hard-coding them: a claims form whose questions vary by product, an onboarding form configured per tenant. In Angular's reactive forms that means building the control tree at **runtime** and rendering it generically. The clean design splits it into a builder and a renderer that both read the same metadata. ## The metadata ```ts export interface FieldConfig { key: string; label: string; type: 'text' | 'number' | 'select'; required?: boolean; options?: string[]; initial?: string | number | null; showWhen?: {key: string; equals: string | number}; } ``` ## The builder A pure function maps configuration to controls. Keeping it free of component state makes it testable in isolation: ```ts import {FormControl, FormRecord, ValidatorFn, Validators} from '@angular/forms'; type FieldValue = string | number | null; export function buildForm(fields: FieldConfig[]): FormRecord<FormControl<FieldValue>> { const form = new FormRecord<FormControl<FieldValue>>({}); for (const f of fields) { const validators: ValidatorFn[] = f.required ? [Validators.required] : []; form.addControl(f.key, new FormControl<FieldValue>(f.initial ?? null, validators), {emitEvent: false}); } form.updateValueAndValidity(); return form; } ``` Points to note: - Validators come from the **description**, not from the renderer, so validation does not depend on which template branch is shown. - `{emitEvent: false}` during construction avoids a burst of events; one `updateValueAndValidity()` at the end settles the status. - Because every control here shares one value type, a `FormRecord` fits. How to type heterogeneous dynamic groups is a separate topic. ## The renderer ```html <form [formGroup]="form"> @for (field of visibleFields(); track field.key) { <label [for]="field.key">{{ field.label }}</label> @switch (field.type) { @case ('select') { <select [id]="field.key" [formControlName]="field.key"> @for (opt of field.options ?? []; track opt) { <option [value]="opt">{{ opt }}</option> } </select> } @case ('number') { <input type="number" [id]="field.key" [formControlName]="field.key" /> } @default { <input [id]="field.key" [formControlName]="field.key" /> } } } </form> ``` `[formControlName]` takes an expression, so the key comes from the metadata. Every key the template binds must exist in the group: a `formControlName` with no matching control throws when the directive tries to find it. ## Conditional fields A field such as `companyName` that appears only when `customerType` equals `'business'` has two Angular implementations: | Approach | Value | Validation | Entered text | |---|---|---|---| | `removeControl` / `addControl` | Key absent while hidden | Rules gone while hidden | Lost unless you keep the control | | `disable()` / `enable()` | Key omitted from `value`, present in `getRawValue()` | Skipped while disabled | Kept | With either, the trigger is the controlling field's `valueChanges`, and the template's `visibleFields()` must follow the same rule so it never binds a key that was removed. Keeping the removed `FormControl` instance in a map and re-adding it restores the user's text if they switch back. ## Judgement calls 1. **Build once per metadata version.** Rebuilding the whole group on every change discards values, touched state and subscriptions. Rebuild only when the metadata itself changes. 2. **Keep one source of truth for visibility.** If the builder, the subscription and the template each implement `showWhen`, they drift. Put the rule in one function used by all three. 3. **Validate the metadata.** An unknown `type` falls into `@default`; a duplicated `key` makes `addControl()` keep the first control silently. Check both when the metadata arrives. 4. **Server stays authoritative.** Client rules derived from metadata improve feedback, but the server must validate the submitted payload against the same configuration. ## Testing the builder Because `buildForm()` is a pure function, most of the behaviour can be tested without rendering anything: - Pass sample metadata and assert that `Object.keys(form.controls)` matches the configured keys in order. - Assert that a field marked `required` has `hasValidator(Validators.required)` true; this works because `Validators.required` is a single shared function compared by reference. - Assert that initial values land in `form.getRawValue()`. - Feed a duplicated key and assert that your metadata check rejects it before `addControl()` can silently keep the first control. The renderer then needs only a light component test that each `type` produces the expected element. ## What this answer leaves to other topics The framework-neutral questions (what a removed field's value should mean, how dependent option lists reset) and how to type mixed-type dynamic groups are covered elsewhere; here the focus is the Angular mechanics of building and binding the tree at runtime.
- What happens if the template binds [formControlName]="field.key" for a key the group does not contain?The `formControlName` directive cannot find the control when it registers and throws an error such as `Cannot find control with name: 'companyName'`. Keep the list the template iterates in step with the controls, for example by deriving both from the same visibility rule, or render only keys that `form.contains()` reports.
- When would you disable a conditional field instead of removing it?When its value should survive being hidden, for example a user toggling back and forth. A disabled control keeps its value, is skipped by validation and omitted from `value`, and still appears in `getRawValue()`. Remove the control when the field must not exist in any payload and its state should not be kept.
saying these in an interview costs you the question
- Rebuild the whole FormGroup whenever any field value changes.
- Put validators in the template branch so each input type validates itself.
- addControl() with an existing key replaces the old control.
- A disabled control's value is cleared, so hidden data is lost.
- formControlName must be a static string, so metadata keys cannot be bound.