Skip to main content

Node types

Import these types from @ngblocks/form-nodes with import type or an inline type import. They describe existing nodes; they are not constructors or Angular dependencies. Create nodes with field(), group(), form(), and array(). Let those factories infer types when you own the declaration, and use explicit types for reusable functions, component inputs, and contracts.

Choose a type

What the consumer knowsTypeAPI access
Only that the value is a nodeAnyNode$api
Common direct members are not shadowed by childrenDynamicNodeDirect members, .$api, or $api
Field value typeFieldNode<TValue>Direct field members
Group child structureGroupNode<TChildren>Inferred children and group members
Form child structureFormNode<TChildren>Inferred children and form members
Array item node typeArrayNode<TItem>Array operations and typed items
Field category, unspecified valueFieldNodeDirect field members
Group category, unspecified childrenGroupNode$api
Form category, unspecified childrenFormNode$api, including submit()
Array category, unspecified itemsArrayNodeDirect array members; item operations through $api

For concrete groups and forms, a child can shadow a direct API member. Use .$api when that known declaration needs it, or $api when even api may be a child. See Tree navigation and API access.

FieldNode

FieldNode<TValue = any, TParent = AnyNode>

Describes one field. TValue is its complete value type, including null or undefined when those values are allowed. It can be a scalar, object, date, or array; an array-valued field does not become an ArrayNode.

For example, field('Marco') infers FieldNode<string>, while field.nullable('Marco') infers FieldNode<string | null>. A FieldNode<string> input requires the non-nullable contract. Omit the generic argument (FieldNode) to accept fields with unrelated value types. Its value becomes any, so reads and writes no longer enforce a specific value shape. It still accepts only fields, not forms, groups, or arrays.

Calling the node returns TValue. Direct state and actions include valid(), set(), reset(), and markAsTouched(). Leaf patch() is available through the API access paths. See the field() reference for options and the full member list.

GroupNode

GroupNode<TChildren = never, TParent = AnyNode>

Describes an object-shaped group. TChildren maps child names to node types, not raw value types: { city: FieldNode<string | null> } describes a group containing a nullable city field. The group's value is computed from its children and retains their value types.

Groups provide structural and shared state operations but do not own an independent submission workflow. Both group({ ... }) and structural object shorthand create group nodes. Use explicit group() when configuring group validators or options, or when teaching the primitive itself. Without generic arguments, GroupNode describes unspecified children, not an empty group. Use $api for state and operations, because child names may shadow direct members. Child enumeration exposes AnyNode; no direct child names are invented. GroupNode<{}> explicitly describes an empty declared structure. See the group() reference.

FormNode

FormNode<TChildren = never, TParent = AnyNode>

Describes a node created by form(). As with GroupNode, TChildren maps names to child node types. A form additionally owns the submission workflow and exposes submit(). Nested form() declarations are also FormNode instances; the type does not mean that a node has no parent.

Without generic arguments, FormNode accepts root and nested forms with unspecified children. Use $api for state and operations, including node.$api.submit(). Child enumeration exposes AnyNode. FormNode<{}> explicitly describes an empty declared structure.

FormNode means a form specifically; AnyNode accepts every primitive.

FormNode is a model type. For Angular template imports and directive queries, use FormNodeDirective. See the form() reference for options and operations.

ArrayNode

ArrayNode<TItem = AnyNode, TParent = AnyNode>

Describes a collection of item nodes. TItem is a node type, such as a group representing one contact, or FieldNode<number | null> for numeric field items. It is not the raw item value and it is not an array type.

ArrayNode<GroupNode<{ email: FieldNode<string | null> }>> describes object items. ArrayNode<FieldNode<number | null>> describes numeric field items. The collection's value is an array of the item values, and operations such as at() and push() preserve the item shape. Omit the generic argument (ArrayNode) to accept arbitrary item and ancestor types, including arrays nested under forms or groups. It exposes array operations directly and types its items as AnyNode; use item.$api for their operations. Its value is any[]. Reading an index or calling at() may return undefined when the item does not exist. See the array() reference.

The never default in form and group signatures selects the unspecified view. It does not mean that these nodes contain no values or children. Supply a child or item type to retain precision.

Concrete types and parent inference

The second parameter on concrete node types is the parent node type. Factories and parent composition infer it for attached children. You usually do not need to spell it out. Use typeof myForm.child when a consumer must preserve the exact attached node type, including its ancestry. A hand-written type with an omitted parent does not necessarily preserve those navigation details, particularly for arrays.

concrete-node-types.typecheck.ts
import { array, field, form, group, type FieldNode, type GroupNode, type FormNode, type ArrayNode, type FormNodeValue } from '@ngblocks/form-nodes';

type AddressChildren = {
city: FieldNode<string>;
postalCode: FieldNode<string>;
};

type ProfileChildren = {
username: FieldNode<string>;
address: GroupNode<AddressChildren>;
roles: ArrayNode<FieldNode<string>>;
};

const profile: FormNode<ProfileChildren> = form({
username: field.strict('Marco'),
address: group({
city: field('Zurich'),
postalCode: field('8000'),
}),
roles: array(field('reader')),
});

type ProfileValue = FormNodeValue<typeof profile>;
type UsernameValue = FormNodeValue<typeof profile.username>; // string
type RolesValue = FormNodeValue<typeof profile.roles>; // string[]

// typeof preserves the actual parent as well as the field's value type.
type AttachedUsername = typeof profile.username;

function changeUsername(username: AttachedUsername) {
username.set('Lia');
}

changeUsername(profile.username);

For contracts expressed in raw values instead of child nodes, use FormValueContract<TValue>. To extract a node's value type, use FormNodeValue<TNode>.

AnyNode

AnyNode is the common callable contract for all four primitives. It has no generic parameters. It accepts nodes with unknown child names, including nested and detached nodes. Its value type is unspecified (any); retain the inferred or concrete type when value precision matters.

Use myAnyNode.$api for state and operations. Names such as reset, valid, and even api can refer to children on a form or group. $api always refers to the node API. Calling the node itself still reads its value.

TypeScript may suggest function members such as call, apply, bind, name, and length. They are not a reliable generic node API. Hiding them with private type members would exclude valid nodes that override those names, including arrays with a public length() signal.

isFormNode(value) accepts unknown and narrows it to AnyNode. It checks identity, not a particular primitive kind, value type, or absence of name collisions.

DynamicNode

DynamicNode has no generic parameters. It exposes the shared state and actions directly, including valid(), touched(), set(), reset(), and markAsTouched(), plus .$api and $api. The direct surface omits patch(), since leaf patching uses the API access paths. Native callable members are hidden. Category-specific actions such as form submission or array insertion require a category-specific type.

Use this view when you know the declaration does not shadow its members. It is not a wrapper, a collision detector, or a way to make a shadowed method callable. Do not assert an arbitrary AnyNode as DynamicNode just to bypass a type error. Prefer $api for nodes from declarations you do not control, including nodes obtained through runtime lookup.

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.');
}

Component inputs

An input signal wraps the node. In these examples, node() reads the input and obtains the node; node()() would read that node's value. Choose AnyNode for shared status and a category-specific type only when the component needs that category's operations.

node-types.typecheck.ts
import { Component, input } from '@angular/core';
import { array, field, form, group, type AnyNode, type FieldNode, type FormNode, type GroupNode, type ArrayNode } from '@ngblocks/form-nodes';

@Component({
selector: 'app-node-status',
template: `<span>{{ node().$api.validationStatus() }}</span>`,
})
export class NodeStatusComponent {
node = input.required<AnyNode>();
}

@Component({
selector: 'app-node-tools',
template: `
<button (click)="field().markAsTouched()">Touch field</button>
<button (click)="group().$api.markAsTouched()">Touch group</button>
<button (click)="form().$api.submit()">Submit form</button>
<button (click)="array().push()">Add item</button>
`,
})
export class NodeToolsComponent {
field = input.required<FieldNode>();
group = input.required<GroupNode>();
form = input.required<FormNode>();
array = input.required<ArrayNode>();
}

@Component({
selector: 'app-profile',
imports: [NodeStatusComponent, NodeToolsComponent],
template: `
<app-node-status [node]="form" />
<app-node-tools
[field]="form.name"
[group]="form.preferences"
[form]="form"
[array]="form.contacts"
/>
`,
})
export class ProfileComponent {
form = form({
name: field('Marco'),
preferences: group({ newsletter: field(true) }),
contacts: array({ email: field('') }),
});
}
TypePurpose
NodeApiCommon state and operations object, accessed through $api
FieldApi<TValue>, GroupApi<TChildren>, FormApi<TChildren>, ArrayApi<TItem>Primitive-specific API objects
FormNodeBinding<TNode>A rendered binding with its host element and node signal
FormNodeDirective<TNode>The public directive instance view; its value is used in Angular imports and queries

All node views expose optional message (string | undefined) and binding-specific formNode on errors returned by errors(), allErrors(), and getError(). This includes AnyNode.$api and the direct DynamicNode API. A message can be absent on a custom error.

Error entries and getError() results retain the selected node category in targetNode, including when generic arguments are omitted.

Node types belong in TypeScript annotations. FormNodeDirective or FormNodesModule belongs in Angular component imports.

NodeValueSignal

NodeValueSignal<TValue, TSet = TValue> describes the nested value facade shared by all node kinds. It is an Angular signal with committed and control signals, each exposing a complete-value set() method. See value views for signatures, examples, equality, and debounce.

Bare FieldNode annotations preserve value.committed() and value.control() with any values. Use FieldNode<TValue> to preserve a known value type. The three value signals hide native function members in IntelliSense; see the typed example.

CallableNodeApi

CallableNodeApi<TApi> combines an API contract with Signal<ReturnType<TApi['value']>>. Node $api properties use this callable facade. It preserves precise value and setter types while preventing children from overwriting operations. See callable API reads and examples.

Browse Public types for a dedicated page for every exported type, including exact declarations, generic parameters, and related contracts.