Form nodes
Use isFormNode(value) to check whether an unknown value
is a field, form, group, or array node. The helper narrows the value to the shared AnyNode type.
Form Nodes represents every part of a form as a node:
field()creates a leaf value.form()combines named child nodes into an object value.array()manages an ordered collection of repeated node definitions.
🔌 Use nodes with Angular signal utilities​
Every field, form, group, and array is an Angular Signal<T> of its exposed committed value.
Pass a node directly to a utility that accepts Signal<T>; isSignal(node) also returns true.
Fields retain their inferred value type: field('Marco') is a Signal<string>, while
field.nullable('Marco') is a Signal<string | null>.
import { field, form } from '@ngblocks/form-nodes';
import { computed, isSignal, type Signal } from '@angular/core';
const uppercase = (source: Signal<string | null>) => computed(() => source()?.toUpperCase() ?? '');
const profile = form({ name: field('Marco') });
const displayName = uppercase(profile.name);
isSignal(profile.name); // true
displayName(); // 'MARCO'
if (!isSignal(profile.name) || !isSignal(profile) || displayName() !== 'MARCO') {
throw new Error('Nodes must be usable as Angular signals.');
}
profile.name.set('Lia');
displayName(); // 'LIA'
if (displayName() !== 'LIA') {
throw new Error('Signal consumers must observe updated node values.');
}
This also works with an effect-based utility such as delaySignal(source: Signal<T>, wait?: number).
The utility still needs the injection context or injector required by Angular effect().
Creating and reading the node itself does not require an injection context.
Consumers observe committed values and configured equality. Pending control input becomes visible
when committed, for example by flush(). Use node.value.control when a utility should observe
pending input instead. Nodes also support writable utilities as described below.
Writable signal utilities​
Fields, arrays, and forms/groups without colliding child names can be passed directly to utilities
accepting Angular's WritableSignal<T>. Reads, set(), and update() use the node's existing value
and writing behavior: validation and parent propagation still run, programmatic writes cancel
pending input, and they do not mark the form dirty or touched. No adapter or synchronized copy is
needed. .$api supports the same contract without child-name collisions.
import { computed, type WritableSignal } from '@angular/core';
import { array, field, form } from '@ngblocks/form-nodes';
function increment(value: WritableSignal<number>) {
value.update(current => current + 1);
}
const profile = form({
age: field.strict(18),
users: array({ username: field('') }),
});
increment(profile.age);
profile.age(); // 19
const age = profile.age.asReadonly();
const doubled = computed(() => age() * 2);
if (doubled() !== 38 || age !== profile.age.$api.asReadonly()) {
throw new Error('Readonly views must share identity and track the exposed value.');
}
profile.age.set(20);
if (doubled() !== 40 || profile.dirty()) {
throw new Error('External writes must propagate without marking the form dirty.');
}
function append<T>(items: WritableSignal<T[]>, value: T) {
items.update(current => [...current, value]);
}
append(profile.users, { username: 'Ada' });
if (profile.users.at(0)?.username() !== 'Ada') {
throw new Error('Writable array utilities must update the live form tree.');
}
// Use the API facade when a child name shadows an operation.
const labels = form({ set: field('draft') });
const writable: WritableSignal<{ set: string }> = labels.$api;
writable.set({ set: 'published' });
if (labels.set() !== 'published') {
throw new Error('The API facade must preserve child-name collisions.');
}
The utility's value type must match the node. field(18) infers number, so it matches
WritableSignal<number>; use field.nullable(18) for WritableSignal<number | null>.
For forms and groups with children named set, update, or asReadonly, pass node.$api.
Broad AnyNode, FormNode, and GroupNode annotations also use .$api because their child names
are unknown. Concrete inferred types retain direct access when there is no collision.
Aggregate setters accept more than their read value type (for example, array.set(null) clears
an array). A generic utility that infers its type from writes can therefore infer a wider type.
When needed, pass the read type explicitly, such as utility<ReturnType<typeof profile>>(profile),
or annotate the argument as WritableSignal<ReturnType<typeof profile>>.
Readonly value views​
node.asReadonly() returns a stable, live Signal<T> of the exposed value. The node and its
.$api.asReadonly() return the same signal. It has no set(), update(), child nodes, or form
operations. Calling it tracks dependencies and respects configured equality and pending debounce.
The method can be extracted without binding a receiver and works outside an injection context.
This method does not set the form's readonly state: use markAsReadonly() for that. As with
Angular signals, a readonly view does not freeze or clone object values. Existing node semantics,
including array reconciliation and equality, continue to apply; writable compatibility does not
turn aggregate values into an unrelated signal store.
⚡ Think of a field as a signal with form features​
Conceptually, field() starts from the same value-access pattern as a normal writable Angular
signal(): call it to read its current value, and use set() or update() to change that value.
const angularName = signal('Marco');
angularName(); // 'Marco'
angularName.set('David');
const formName = field('Marco');
formName(); // 'Marco'
formName.set('David');
formName.update(name => name.toUpperCase());
The difference is that a field is also a form node. In addition to signal-style value access, it
provides validation, errors, touched and dirty state, disabled/readonly/hidden state, reset,
debounce, focus, tree navigation, and Angular control binding. A normal signal() that stores the
same value does not provide those form behaviors.
This mental model also applies to form(), group(), and array(): each primitive is callable to
read its aggregate committed value and exposes set() and update(), while adding the structural
and state behavior appropriate to that node kind.
Calling the node itself is the preferred way to read its committed value.
const myForm = form({
name: field('Marco'),
});
myForm.name(); // 'Marco'
myForm.name.set('David');
myForm.name.touched();
myForm.name.markAsTouched();
value.control() has different semantics and represents an immediate value buffered from a bound
UI control before debounce completes. The Values and state page documents
the explicit alternative value paths for generic infrastructure.
Aggregate state is derived from descendants. A form becomes invalid when one of its descendants
is invalid, while errors() remains scoped to errors owned directly by the current node. Use
allErrors() to collect errors from the complete subtree.