Node API
This reference groups the public signals and operations available on fields, forms, and arrays. Exact value and parent types remain inferred from the node tree.
For constructor signatures, options, and primitive-specific examples, see form(),
field(), and array().
Calling a node directly—such as profile.name() or profile()—is the preferred committed-value
read. Use value.control() only when the immediate, potentially debounced value owned by a bound
control is specifically needed. The Values and state
page documents the explicit alternative paths for generic infrastructure.
Use direct members for actions and state on every node: name.set(), items.push(),
profile.patch(), and profile.valid(). The Tree navigation and API access
guide documents .$api only for name collisions and generic infrastructure.
Writable signal compatibility
Nodes support utilities accepting WritableSignal<T> through their existing set() and update()
methods. Use .$api when child names collide with set, update, or asReadonly, or when a broad
node annotation leaves child names unknown. Nullable node values require nullable utility types.
asReadonly(): Signal<T> returns the same live readonly value signal on every call, shared with
node.$api.asReadonly(). It tracks the exposed committed value and configured equality, exposes no
writing or form methods, and does not mark the node readonly or freeze its data.
See writable utilities and the executable example.
Choosing a node type
See the Node types reference for FieldNode, GroupNode, FormNode,
ArrayNode, AnyNode, and DynamicNode, including usage without generic arguments. It documents
parameters, component inputs, parent inference, and the distinction between models and bindings.
AnyNode or DynamicNode?
Use AnyNode with $api when child names are unknown. Use
DynamicNode for direct common members when the declaration
is known not to shadow that surface. Neither type wraps the node or repairs name collisions.
Recognizing nodes with isFormNode()
Use isFormNode(value) to check an unknown value and narrow it to AnyNode.
See its dedicated reference for the signature, examples, and package-instance limitations.
🧭 API map
| Node concern | Details |
|---|---|
| Values, reset, parent, and path | Shared value and tree API |
| Errors, validity, constraints, and pending state | Validation API |
| Touched, dirty, disabled, readonly, and hidden | Interaction and availability API |
| Form children, patching, debounce, focus, and submission | Form-specific API |
| Array items, collection helpers, and structural operations | Array-specific API |
| Concrete rendered controls | Binding API |
🌳 Shared value and tree API
| Member | Description |
|---|---|
myNode() | Preferred read of the current committed value |
nodeType() | Stable primitive discriminant: 'field', 'group', 'form', or 'array' |
value.control() | Immediate value of a directly bound control; it may differ during debounce |
set(value) | Assigns a complete value |
update(updater) | Computes and assigns a complete value |
reset() / reset(value) | Clears interaction state, optionally replacing the value |
resetToInitial() | Restores captured initial values and clears interaction state; see reset and restore |
form() | Nearest explicit form workflow, or null when none owns the node |
root() | Complete structural root; every standalone root returns itself |
parent() | Direct parent or null at the root |
path() | Reactive string path from the root |
keyInParent() | Property name, array index, or null |
Forms and arrays additionally expose patch(), aggregate flush(), debouncing(), and subtree
focus(). Use set() rather than patching a leaf field.
nodeType() returns a precise literal for statically known nodes and the complete union for a
generic node. This is useful when generic infrastructure needs to branch by primitive without
testing for incidental members:
if (node.nodeType() === 'array') {
// Handle an array node.
}
On forms and groups, a child named nodeType can shadow the direct method. Use
myForm.$api.nodeType() when code must be collision-safe.
✅ Validation API
| Member | Description |
|---|---|
validators() | Current normalized validator collection |
setValidators(source) | Replaces validators |
errors() | Errors owned directly by this node |
errors({ descendants: true }) | Own and descendant errors |
allErrors() | Shortcut for errors({ descendants: true }) |
getError(kind) | First own error of a kind |
valid() / invalid() | Aggregated validity |
pending() | Current asynchronous validation state |
validationStatus() | 'valid', 'invalid', or 'unknown' while pending without errors |
required() | Whether active validators require a value |
Fields also expose constraint metadata through min(), max(), minLength(), maxLength(), and pattern().
👆 Interaction and availability API
| Signals | Operations |
|---|---|
touched() / untouched() | markAsTouched(), markAsUntouched() |
dirty() / pristine() | markAsDirty(), markAsPristine() |
disabled() / enabled() | disable(message?), enable() |
readonly() / writable() | markAsReadonly(), markAsWritable() |
hidden() / visible() | hide(), show() |
submitting() | Readonly submission state |
debouncing() | flush() |
disabledReasons() lists inherited and local causes with their source nodes.
markAsUntouched() and markAsPristine() clear only the selected node's own marker. A touched
or dirty descendant can keep a form, group, or array's aggregate state active. Use reset() to
clear interaction state throughout a subtree while preserving its committed values.
import { field, form, array } from '@ngblocks/form-nodes';
const profile = form({
name: field('Ada'),
contacts: array({
email: field('ada@example.com'),
}, {
initialValue: 1,
}),
});
profile.markAsTouched();
profile.markAsUntouched();
profile.touched(); // true
profile.name.touched(); // true
if (!profile.touched() || !profile.name.touched()) {
throw new Error('Clearing a parent marker must preserve touched descendants.');
}
profile.contacts.markAsUntouched();
profile.contacts.touched(); // true
if (!profile.contacts.touched()) {
throw new Error('A touched item must keep its array touched.');
}
profile.reset();
profile.touched(); // false
profile.contacts.touched(); // false
if (profile.touched() || profile.contacts.touched() || profile.name.touched()) {
throw new Error('Reset must clear interaction markers throughout the subtree.');
}
profile.markAsTouched({ skipDescendants: true });
profile.markAsUntouched();
profile.touched(); // false
if (profile.touched()) throw new Error('An own-only marker must be cleared independently.');
🧩 Form-specific API
| Member | Description |
|---|---|
children | Stable readonly map of named child nodes |
get(key) | Reads a child by runtime key, or returns undefined |
add(...) / remove(key) | Explicitly attaches or detaches runtime children |
patch(value) | Recursively updates supplied branches |
submit() | Runs configured submission behavior and returns Promise<boolean> |
Initially declared children are also direct properties. Runtime children are deliberately
available only through the node returned by add(), get(key), which lets
TypeScript and Angular reject misspelled direct properties.
📚 Array-specific API
| Member | Description |
|---|---|
items() / length() | Reactive item collection and size |
[index] / at(index) | Reads an item node |
push(value?) / insert(index, value?) | Creates an item from the template |
removeAt(index) / clear() | Removes items |
moveUp(index) / moveDown(index) | Moves one position |
move(from, to) / swap(a, b) | Reorders nodes without recreating them |
patch(values) | Reconciles a complete collection, like set() |
Arrays are iterable and expose forEach, map, filter, find, findIndex, some, every, includes, and indexOf over item nodes.
🔌 Binding API
A FormNodeDirective<TNode> obtained through viewChild() exposes:
| Member | Description |
|---|---|
node() | Currently bound typed node |
errors() | Node errors relevant to this concrete binding |
element | Host element |
injector | Host injector |
focus() | Focuses this concrete control |
flush() | Commits this binding's pending control value |
reset() | Resets this binding and its current node |
Import public APIs only from @ngblocks/form-nodes. _FormNode is exported solely for Angular AOT/linker infrastructure and is not an application API.
For compatibility with Angular model(), ControlValueAccessor, NgControl,
and native controls, see Advanced custom controls.
For scheduling, detached-node lifetime, multiple bindings, and defensive runtime behavior, see
Advanced behavior and edge cases.
Nested value views
Use node.$api.value.committed() to observe the latest committed data before configured equality,
and node.$api.value.control() for the node's own pending input. Each has a set() method.
See the complete value reference for all five entries and an executable example.
Callable API
Signature: CallableNodeApi<TApi extends { value: Signal<any> }>.
$api() reads the exposed value with the same inferred type and reactive semantics as calling
the node. All API signals and operations remain available on that stable function, which is distinct from the node and its
value signal. Exposed custom equality still applies; use api.value.committed() for raw committed
data. Neither calling the API nor obtaining it changes validation or interaction state.
Child properties cannot overwrite API members. Array APIs keep their real length() signal;
normal function members are hidden from IntelliSense on concrete API types. This does not remove
JavaScript's function prototype or make unsupported properties part of the public contract.
import { computed, isSignal } from '@angular/core';
import { field, form, array, isFormNode } from '@ngblocks/form-nodes';
const profile = form({
submitted: field('draft'),
value: field('child value'),
api: field('child api'),
names: array(field(''), { initialValue: ['Ada'] }),
});
const api = profile.$api;
const snapshot = computed(() => api());
api(); // { submitted: 'draft', value: 'child value', api: 'child api', names: ['Ada'] }
api.submitted(); // false
api.children.submitted(); // 'draft'
api.value(); // { submitted: 'draft', value: 'child value', api: 'child api', names: ['Ada'] }
if (!isSignal(api) || isFormNode(api)) throw new Error('The API must be an Angular signal, not a node declaration.');
await api.submit();
if (!api.submitted() || profile.submitted() !== 'draft') throw new Error('Child names must not overwrite API state.');
api.patch({ value: 'updated' });
if (snapshot().value !== 'updated') throw new Error('Callable API reads must track exposed value changes.');
profile.names.$api.push('Grace');
profile.names.$api(); // ['Ada', 'Grace']
profile.names.$api.length(); // 2
if (profile.names.$api.length() !== 2) throw new Error('The array API must preserve its length signal.');
api.resetToInitial();
if (api.submitted() || snapshot().value !== 'child value') throw new Error('API actions must preserve reset behavior.');
useClosestFormState() exposes this callable API through its reactive
formNode property. Read formState.formNode()?.submitted() without child-name collisions.