Skip to main content

Dynamic object children

Use dynamic object children when a form or group gains named controls at runtime. For repeated ordered entries, use array() instead.

The complete executable example verifies insertion, lookup, aggregate values, and detachment.

🌳 Add one child

add(name, definition) accepts the same shorthand as an initial form() or group() declaration, attaches the normalized node, and returns it with its exact inferred type:

const profile = form({ name: field('Marco') });

const age = profile.add('age', field(23));

age(); // 23
age.parent(); // profile
profile();
// Expected output: { name: 'Marco', age: 23 }

The new child immediately participates in aggregate value, validation, pending, touched, dirty, disabled, readonly, hidden, debounce, focus, and injector inheritance.

🌳 Add several children

Pass an object to add several definitions in one structural update. Plain nested objects become group() nodes and concise values become field() nodes, just as they do in the original form() declaration:

const added = profile.add({
nickname: field('Mark'),
address: {
city: field('Zurich'),
},
});

added.nickname(); // 'Mark'
added.address.city(); // 'Zurich'

The operation validates every destination key and definition before normalizing or attaching anything. Existing keys, and $api are rejected. Array values become fields; use an explicit array(...) for a dynamic collection with item nodes. Use field(objectValue) whenever a plain object should remain one atomic value instead of becoming a group.

Both add() signatures intentionally preserve the cardinality of their input. Adding one named definition returns that exact attached node; adding an object returns an exact keyed map containing all attached nodes. This keeps the common single-control call concise while retaining precise types for an atomic multi-control addition.

Unlike keys in the initial form() definition, a dynamically added key is not installed as a direct property. This keeps misspelled properties detectable by TypeScript and Angular's strict template checker.

Static children and dynamic children use different access paths

Children present in the original form() or group() declaration support direct property access. Children attached later with add() do not.

Child kindDeclarationSupported access
Initially declaredform({ name: field('') })profile.name
Added dynamicallyprofile.add('age', field(23))returned node, profile.get('age')

Neither profile.age nor profile['age'] is supported for a dynamically added child. This is intentional: allowing arbitrary properties would also allow a typo such as profile.mistypedName to pass type checking.

💡 Look up a runtime key

Use get(key) for a runtime key. It returns DynamicNode | undefined. DynamicNode exposes every state and operation shared by all node kinds, such as value, disabled, errors, set(), and reset(). Primitive-specific operations such as submit() are not available until the node is narrowed. Children declared in the original definition retain their exact direct-property types.

profile.get('age')?.value(); // 23
profile.get('unknown'); // undefined
profile.name(); // string | null

const key: string = configuration.controlName;
profile.get(key); // DynamicNode | undefined

profile.age does not compile merely because age was added at runtime. Retain the exact node returned by add() when its type matters. Assignment such as profile.age = field(23) is not an alternative spelling of add(): structural mutation remains explicit.

The same rule applies in Angular templates. Narrow the optional lookup before binding it:

<!-- Correct: name was part of the original declaration. -->
<input [formNode]="profile.name" />

<!-- Correct: age was added at runtime. -->
@if (profile.get('age'); as age) {
<input [formNode]="age" />
}

<!-- Does not compile: age is not a declared direct property. -->
<input [formNode]="profile.age" />

<!-- Does not compile, catching the typo. -->
<input [formNode]="profile.mistypedName" />

If the code that adds the child also owns the template, retaining the result gives the clearest and most precise binding:

const age = profile.add('age', field(23));
<input [formNode]="age" />

📚 Remove a dynamic child

remove(name) detaches and returns a dynamically added child:

const removed = profile.remove('age'); // DynamicNode | undefined

profile.get('age'); // undefined
removed?.parent(); // null

The detached node remains usable. It no longer contributes value, validation, interaction state, focus, or pending work to its former parent. Initially declared children cannot be removed because their presence is guaranteed by the form's static type.

A removed node can be attached to another form or group. It then receives the new parent, path, root, inherited state, debounce, and injector ownership. Replace a runtime child explicitly by removing it and adding a new definition under the same key:

const previousAge = profile.remove('age');
const age = profile.add('age', field(36));

previousAge?.parent(); // null
age(); // 36

📐 Value typing

The original definition remains the form's statically known value shape. Runtime children appear in the JavaScript object returned by the form, but code that needs their values should retain the typed node returned by add() or narrow the result of get().

set(), patch(), update(), and reset(value) keep their original fixed-shape input types. They update matching dynamic keys when an untyped runtime object supplies them; omitted dynamic children retain their current value. reset() still clears their interaction state recursively.

🔌 Angular comparison

Angular Signal Forms 22.1.5 derives object and array children from the shape of its writable model; it does not expose an addControl() operation on a field tree. Form Nodes owns explicit nodes, so add() and remove() are deliberate library-specific structural operations.

📐 Enumerating children and static types

children exposes runtime keys as well as precisely typed declared properties. Use node.children[key]?.value() for arbitrary names when noUncheckedIndexedAccess is enabled, or node.get(key) for a lookup that is always typed as optional. Object.values(node.children) includes added children and its inferred element type includes DynamicNode.

forEachChild() excludes dynamically added nodes by default, so its callback's declared-child union matches runtime iteration. Pass { includeDynamic: true } as the second argument to include all immediate children and receive DynamicNode callbacks. A runtime boolean also uses DynamicNode; an omitted option or a literal false preserves the declared union.

🚀 Start with an empty record

Use form({}) or group({}) for a container whose children will be attached with add(). Call forEachChild(callback, { includeDynamic: true }) to visit its added children with DynamicNode callbacks. Without that option, iteration visits nothing and the callback child type is DynamicNode. Object.values(children) still returns all children as DynamicNode[]. For a requested runtime key, get(key) still returns DynamicNode | undefined. Definitions with declared children retain the declared-child union described above. See the form reference for a complete example.