skip to content

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?

level: seniorimportance: should knowfreq 38%

answer

  1. configuration in, controls out
  2. one builder function, one renderer
  3. @switch on field type
  4. addControl/removeControl or disable()

basics

~20 s

Map 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 s

Treat 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 lines
ts
import {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

for a junior

Recall that controls can be created in a loop from configuration, added with addControl, and bound with [formControlName] using an expression.

for a middle

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.

for a senior

Keep visibility rules in one place, avoid full rebuilds that discard state, validate incoming metadata, and keep the server authoritative for the submitted payload.

for a principal

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.