Skip to main content

Tree navigation and API access

A form is both a callable value signal and a typed tree of child nodes. Form Nodes keeps those two views connected without requiring string paths.

🌳 Direct child access

Every form child is exposed directly on the form under its definition key. This is the normal and preferred way to navigate the tree:

const profile = form({
name: field('Marco'),
address: {
city: field('Madrid'),
},
});

profile.name(); // 'Marco'
profile.address.city(); // 'Madrid'

The same children are also available through a stable readonly children map:

profile.children.name === profile.name; // true
profile.children.address.children.city === profile.address.city; // true

The map does not contain a second set of nodes: its entries are the exact nodes already exposed directly. It is useful when code should be deliberately explicit about traversing children, or when generic infrastructure needs the complete named-child collection.

Every key supplied by the user in the initial form() definition takes precedence in the public type over ordinary node API and native callable members. This includes value, reset, api, children, name, and apply. The reserved name $api always provides collision-safe API access.

For example, a declared child takes precedence over children itself:

const response = form({
children: field('domain value'),
status: field(200),
});

response.children(); // 'domain value'
response.$api.children.children(); // 'domain value'
response.$api.children.status(); // 200

In ordinary application code, continue to prefer profile.name and profile.address.city over their longer children paths.

⚙️ Direct members by default

Use node members directly in application code:

SituationPreferred styleReason
Read any node valuemyNode()Nodes are callable value signals
Read field or array statemyField.valid(), myArray.length()The direct API is concise and unambiguous
Run a field or array operationmyField.set(value), myArray.push(value)Prefer the operation directly on the node
Read form state or run a form operationmyForm.valid(), myForm.patch(value)Forms expose their API directly too

Calling a form remains the preferred way to read its complete value. Call its operations and state directly as well:

profile(); // { name: 'Marco', address: { city: 'Madrid' } }
profile.patch({ name: 'Ada' });
profile.reset();
profile.valid();

📖 .$api for collisions and generic code

Every node also exposes the same members through .$api, but ordinary application examples should not use that longer path. It exists for two specific situations:

  • a form child has the same name as an API member; or
  • generic infrastructure needs one uniform object API for fields, forms, and arrays.

Domain names take precedence over direct form API members:

const settings = form({
readonly: field(false),
value: field('domain value'),
});

settings.readonly(); // false
settings.value(); // 'domain value'
settings.$api.readonly(); // form state
settings(); // { readonly: false, value: 'domain value' }

For generic code receiving AnyNode, use node.$api. Unknown child names can override direct operations. Use DynamicNode for direct common state and operations only when the declaration is known not to shadow that surface:

generic-node-api.example.ts
import { field, form, type AnyNode, type DynamicNode } from '@ngblocks/form-nodes';

const response = form({
api: field('v2'),
reset: field('draft'),
});

response.api(); // 'v2'
response.reset(); // 'draft'

function touchNode(node: AnyNode) {
node.$api.markAsTouched();
}

touchNode(response);
if (!response.$api.touched() || response.api() !== 'v2' || response.reset() !== 'draft') {
throw new Error('Generic operations must preserve colliding children and target the node API.');
}

// This declaration is known not to shadow the direct common API.
const profile: DynamicNode = form({ username: field('Marco') });
profile.markAsTouched();
profile.touched(); // true

if (!profile.touched()) {
throw new Error('A compatible DynamicNode must expose direct common operations.');
}

const registration = form({
username: field('', [() => ({ kind: 'unavailable', message: 'Choose another username.' })]),
}, {
validators: [() => ({ kind: 'reviewRequired' })],
});

function errorMessages(node: AnyNode): (string | undefined)[] {
return node.$api.errors().map(error => error.message);
}

errorMessages(registration.username); // ['Choose another username.']
errorMessages(registration); // [undefined]

const registrationView: DynamicNode = registration;
const messages = registrationView.allErrors().map(error => error.message);
if (errorMessages(registration.username)[0] !== 'Choose another username.'
|| errorMessages(registration)[0] !== undefined
|| messages.length !== 2
|| !messages.includes('Choose another username.')
|| registrationView.getError('reviewRequired')?.message !== undefined) {
throw new Error('Generic node APIs must preserve messages, including errors without a message.');
}

See AnyNode or DynamicNode? for the contract of each type. Neither type modifies or wraps a node at runtime.

A field's rarely needed leaf patch() is exposed in the public types only through this uniform API and behaves like set(); application code should normally call field.set(value).

The name api is also a valid child name. $api is the reserved, collision-safe escape hatch:

const response = form({ api: field('v2') });

response.api(); // 'v2'
response(); // { api: 'v2' }
response.$api.valid(); // collision-safe form state

Prefer direct members for application code. Use $api for any child-name collision or when generic infrastructure needs a uniform API.

🌳 Parent, root, and path

Every node exposes reactive tree-location signals:

profile.address.city.keyInParent(); // 'city'
profile.address.city.parent(); // profile.address
profile.address.city.form(); // profile
profile.address.city.root(); // profile
profile.address.city.path(); // ['address', 'city']
  • A root node has parent() === null and path [].
  • form() returns the nearest explicit form() workflow. A nested form returns itself, and every descendant resolves that nested form until another explicit form begins.
  • A standalone field, group, or array has form() === null.
  • root() returns the complete structural root and therefore never returns null. A standalone node returns itself, including a standalone field, group, form, or array.
  • Array item paths use decimal string segments such as ['people', '0', 'name'].
  • Moving an array item updates its path without recreating the node.
  • Detaching an array item clears its parent and path; a retained reference becomes its own root and remains independently usable. Attaching or reparenting it updates both lookups immediately.

An explicit nested form separates workflow ownership from structural ownership. This complete example also covers standalone fields, groups, and arrays:

ancestry-lookups.example.ts
import { array, field, form, group } from '@ngblocks/form-nodes';

const checkout = form({
payment: form({
card: field(''),
}),
});

if (checkout.payment.card.form() !== checkout.payment) {
throw new Error('A nested explicit form must own its descendant workflow.');
}
if (checkout.payment.card.root() !== checkout) {
throw new Error('root() must cross nested form workflow boundaries.');
}

const address = group({ city: field('Zurich') });
const names = array(field(''));
const standalone = field('value');

if (address.form() !== null || address.root() !== address) {
throw new Error('A standalone group must be its own structural root without owning a form.');
}
if (names.form() !== null || names.root() !== names) {
throw new Error('A standalone array must be its own structural root without owning a form.');
}
if (standalone.form() !== null || standalone.root() !== standalone) {
throw new Error('A standalone field must be its own structural root without owning a form.');
}

Both signals are stable and reactive, so validators and effects can observe a node being attached, detached, or moved. Validators access those signals through ctx.node().form() and ctx.root() (the shortcut for ctx.node().root()).

ctx.node and ctx.field are the same readonly signal. Both return the validated node and never return null. Prefer ctx.node() when writing validation that can apply to different primitives.

AccessResult
ctx.node() or ctx.field()The validated node
ctx.node().form()Nearest explicit form workflow, or null
ctx.root() / ctx.node().root()Complete structural root; never null
ctx.parent()Direct parent, or null
ctx.value()Committed value with its inferred type
ctx.node().touched() / ctx.node().dirty()Interaction state of the validated node

ctx.root() is a direct shortcut; form workflow navigation remains ctx.node().form(). Read a value with ctx.value(), or use ctx.node().value() / ctx.field().value() when accessing it through the node. The node signal and its result stay stable across value changes and tree moves. Reading only ctx.node() does not subscribe to the value; read the returned node's value, state, or ancestry to track it.

Interaction, availability, required, and submission signals live on the node. Use ctx.node().touched(), ctx.node().disabled(), or ctx.node().submitting() instead of flat context properties. This applies to synchronous validators and every asyncValidator() callback.

◆ Inline node inference

An inline validator knows the primitive being created. A field validator receives a read-only validation view of FieldNode<TValue>; a form or group validator retains its declared children; an array validator retains its item type. This works for positional validators, options.validators, configured primitives, and inline validator() / asyncValidator() helpers. Omit helper type arguments to let the enclosing primitive infer both the value and the node. Explicit generics on the primitive, such as field.strict<string>(''), still preserve this inference.

inline-validator-nodes.typecheck.ts
import { array, asyncValidator, field, form, group, validator } from '@ngblocks/form-nodes';

const profile = form({
email: field('', {
validators: validator((ctx) => {
const email = ctx.node(); // Field<string>; identical to ctx.field()
return email.touched() && !ctx.value() ? { kind: 'missingEmail' } : null;
}),
}),
address: group({
city: field(''),
}, {
validators: (ctx) => {
const address = ctx.node(); // Group with a typed city child
return address.city()?.trim() ? null : { kind: 'missingCity' };
},
}),
contacts: array({
email: field(''),
}, {
validators: asyncValidator({
params: (ctx) => ctx.node().items().map(contact => contact.email()),
validate: async ({ params }) => {
return new Set(params).size === params.length ? null : { kind: 'duplicateContacts' };
},
}),
}),
}, {
validators: asyncValidator(async (ctx) => {
const profile = ctx.node(); // Form with typed email, address, and contacts children
return profile.email() ? null : { kind: 'incompleteProfile' };
}),
});

void profile;

A validator declared separately cannot acquire its future owner's type retroactively. Its node uses the common field/form/group/array API union unless an exact node type is supplied explicitly. Likewise, specifying only a helper's value generic uses its default owner type; omit the helper's generics for inline inference, or supply its owner generic explicitly. Common members are available on the union; operations unique to a primitive require narrowing.

Knowing the validated node does not infer the enclosing form's parents or sibling keys. Access through the declared tree or an explicitly specialized context retains those exact relationships. Access an API alias through ctx.node().$api or ctx.field().$api; its type follows the node. Validator node types expose values, interaction state, availability, and navigation. Validation results, constraint metadata, and mutations are omitted recursively, including $api, children, array traversal, and parent<TParent>() with an explicit generic. Read values for cross-field rules and return errors; perform mutations outside validation. Runtime node identity is unchanged.

validator-ancestry.typecheck.ts
import { field, form } from '@ngblocks/form-nodes';

const checkout = form({
payment: form({
billing: {
email: field('', [(ctx) => {
const node = ctx.node();
const workflow = node.form();
const tree = node.root();
const parent = ctx.parent();

// Common state is available directly on each returned node.
const interacted = node.dirty() || tree.dirty() || parent?.touched();
if (!interacted && !workflow?.submitting()) return null;

return ctx.value() ? null : { kind: 'missingBillingEmail' };
}]),
},
}),
});

// Access through the declared tree retains the exact child types.
checkout.payment.billing.email.form()?.billing.email.set('ada@example.com');

This executable example verifies both aliases, stable signal identity, and separate value tracking:

validator-field-signal.example.ts
import { computed, isSignal } from '@angular/core';
import { field, form, type ValidatorContext } from '@ngblocks/form-nodes';

let nodeSignal: ValidatorContext<string | null>['field'] | undefined;
const profile = form({
email: field('', [(ctx) => {
if (!Object.is(ctx.node, ctx.field)) {
throw new Error('node and field must expose the same readonly signal.');
}
nodeSignal = ctx.node;
return ctx.value() ? null : { kind: 'missingEmail' };
}]),
});

profile.email.errors();
if (!nodeSignal || !isSignal(nodeSignal)) {
throw new Error('The validator must receive a real Angular signal.');
}

const originalSignal = nodeSignal;
let identityReads = 0;
const validatedNode = computed(() => {
identityReads++;
return originalSignal();
});
const committedValue = computed(() => originalSignal().value());

if (validatedNode() !== profile.email || committedValue() !== '') {
throw new Error('Read the signal for its node and the node value signal for its committed value.');
}

profile.email.set('ada@example.com');
profile.email.errors();
if (nodeSignal !== originalSignal || validatedNode() !== profile.email || identityReads !== 1) {
throw new Error('Value changes must preserve both signal and node identity.');
}
if (committedValue() !== 'ada@example.com') {
throw new Error('Reading the returned node must track its committed value.');
}

The same context is available to synchronous and asynchronous validators, including when, params, validate, and onError. Async execution adds abortSignal; parameterized execution also adds params.

💡 Function property names

Native JavaScript function members such as name, apply, call, and length are hidden from node IntelliSense. A form may use those names for children, and the child remains available normally:

const command = form({
name: field('deploy'),
apply: field(false),
});

command.name(); // 'deploy'
command.apply(); // false

This hiding affects the public type only; node callability and the documented API remain unchanged.

Callable APIs

node.$api is also an Angular signal: calling it returns the same exposed value as node(). The facade also satisfies WritableSignal<T> and provides asReadonly() for a stable readonly value view. Child names never override the API facade. The API itself never receives direct child properties, so its value, set, and submitted members remain safe. Inspect children through children or array collection methods.

The exported CallableNodeApi<TApi> adds the call signature and signal contract to FieldApi, GroupApi, FormApi, or ArrayApi. Native function members are hidden on concrete API types, while API-defined members such as array length() remain visible. The broad AnyNode API stays structural so it can accept every node kind, including arrays with a signal-valued length.

Calls honor exposed equality and debounce. Nested api.value.committed() and api.value.control() retain their distinct read/write behavior. This signal is not a form-node declaration: pass nodes to [formNode], and use the API for generic reads and operations. See the executable example.