Skip to main content

Creating nodes

Form Nodes models a workflow as a tree of field(), form(), array(), and group() nodes. TypeScript infers the complete value shape from that tree.

When a named domain model should check that inferred value without replacing concrete child types, use satisfies FormValueContract<Model>.

🧩 Fields​

Use field() for a leaf value. Fields normally appear inside a form definition:

For structured values, the optional equal setting can retain equivalent committed values and avoid value-triggered revalidation.

import { field, required } from '@ngblocks/form-nodes';

const myForm = form({
name: field('Marco', {
validators: [required],
}),
});

Fields infer nullability from the generic and initial value. myForm.name is therefore FieldNode<string>. field.strict() additionally rejects nullish initial values:

const name = field.strict('');

name.set('Lia');
// name.set(null); // TypeScript error

Use field.nullable('') or createFormPrimitives({ nullable: true }) to add null explicitly to fields and field shorthands.

A field created without an initial value starts at null:

const nickname = field<string>();

If the initial value is the literal null and no generic supplies the eventual type, the field is inferred as FieldNode<unknown>:

const myForm = form({
unspecifiedValue: field(null), // Field<unknown>
deferredValue: field(undefined), // Field<unknown>, initial value is undefined
nickname: field<string>(null), // Field<string | null>
});

The first form is intentionally safer than FieldNode<any>: it accepts later values, but consumers must narrow a read before using it. Prefer the explicit generic when the domain type is known.

A standalone field is fully supported, but a form tree provides the typed parent, path, aggregate value, and state propagation used by most applications.

A field may contain an object or array value and still remain one leaf node. Use this for controls that edit a structured value atomically, such as a multi-select editing string[]. Use nested form() or array() nodes only when the value's parts need independent bindings and state. See Choosing a primitive.

Inside form() or group() definitions, use field(value) as the unambiguous escape hatch for any value that could otherwise be interpreted as structure. In particular, a plain object shorthand creates a nested group; wrapping that same object with field() guarantees one atomic field:

const defaultCompany = { companyId: 23, companyName: 'Apple' };

const profile = form({
company: field(defaultCompany),
});

Structural shorthand reads only own enumerable string-keyed data properties. It ignores inherited and non-enumerable properties and rejects accessors, symbol keys, and __proto__ before creating the tree. A normalization error reports the complete declaration path and, when the object may be application data rather than structure, recommends wrapping it with field(value).

🧩 Declaration shorthand matrix​

Shorthand is intentionally predictable: atomic values become fields, plain objects become groups, and dynamic collections require an explicit array().

Declaration inside form(), group(), or an object array templateExplicit equivalentInferred node valuePrefer an explicit primitive when…
name: ''name: field('')string | nullThe field needs validators, options, or a generic
age: 0age: field(0)number | nullThe field must be strict or configured
active: falseactive: field(false)boolean | nullThe field needs configuration
birthday: new Date()birthday: field(new Date())Date | nullThe date needs field configuration
empty: null or undefinedempty: field(null) or field(undefined)unknownThe future type is known: use field<T>()
roles: ['admin']roles: field(['admin'])string[] | nullItems need nodes: use array(field(''))
address: { city: '' }address: group({ city: field('') }){ city: string | null }The branch needs validators or options: use group()
company: classInstancecompany: field(classInstance)Company | nullMaking the atomic boundary explicit improves readability
company: { ...data }company: group({ ... })Object assembled from child valuesThe object is one atomic value: use field(company)
child: existingNodeUnchangedExisting node valueNeverβ€”explicit nodes are preserved
declaration-shorthand-matrix.example.ts
import { array, field, form, type FieldNode, type GroupNode } from '@ngblocks/form-nodes';

class Company {
constructor(readonly name: string) {}
}

const profile = form({
name: '',
age: 0,
roles: ['reader'],
address: { city: 'Zurich' },
company: new Company('Form Nodes'),
atomicAddress: field({ city: 'Bern' }),
contacts: array({ email: '' }, {
initialValue: [{ email: 'team@example.com' }],
}),
}, {});

const name: FieldNode<string> = profile.name;
const roles: FieldNode<string[]> = profile.roles;
const address: GroupNode<{ city: FieldNode<string> }, typeof profile> = profile.address;

if (profile.name() !== '' || profile.age() !== 0 || profile.roles()?.[0] !== 'reader') {
throw new Error('Atomic shorthand values should normalize to fields and preserve their values.');
}

if (profile.address.nodeType() !== 'group' || profile.address.city() !== 'Zurich') {
throw new Error('Plain object shorthand should normalize to a group.');
}

if (profile.company.nodeType() !== 'field' || profile.atomicAddress.nodeType() !== 'field') {
throw new Error('Class instances and explicitly wrapped objects should remain atomic fields.');
}

if (profile.contacts.nodeType() !== 'array' || profile.contacts[0]?.email() !== 'team@example.com') {
throw new Error('Explicit arrays should create independently addressable item nodes.');
}

void [name, roles, address];

An empty array shorthand is also one atomic field and widens to unknown[]. Root array([]) is rejected because an empty array cannot describe an item-node template; use array(field<T>()) for a dynamic collection. Definition objects reject symbol child keys, enumerable accessors, and __proto__ before creating any node.

βš™οΈ A container is optional​

You do not have to place fields inside form() or group(). A normal JavaScript object can organize independent nodes when no aggregate node behavior is needed:

const profileFields = {
displayName: field(''),
emailAddress: field(''),
};

profileFields.displayName(); // ''
profileFields.emailAddress.set('marco@example.com');

These fields remain fully usable and bindable, but the object itself is not a node. It cannot be called to read one aggregate value, does not expose aggregate validation or interaction state, and cannot provide set(), patch(), reset(), inherited configuration, child paths, or parent/root navigation. Each field is an independent root.

Wrap the same structure in group() when those tree capabilities are useful but the root does not own submission:

const profileGroup = group({
displayName: field(''),
emailAddress: field(''),
});

profileGroup(); // { displayName: '', emailAddress: '' }
profileGroup.valid();
profileGroup.reset();

Use form() when the root additionally represents a submission workflow or binds to a native <form> element. A root group() is therefore a normal and reasonable choice for settings panels, reusable editors, filter models, and other structured UI that has no independent submit action.

🧩 Forms​

Use form() to combine named nodes into an object:

import { field, form, group } from '@ngblocks/form-nodes';

const profile = form({
name: field(''),
age: field<number>(),
});

profile(); // { name: '', age: null }
profile.name(); // ''
profile.age(); // null

Nested objects are shorthand for groups:

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

profile.address.city(); // ''

Only plain objects are interpreted as structural groups. Arrays, functions, and other object instances become fields automatically. An array's length and contents never determine node structure: declare array(...) explicitly when its items need independent nodes.

Use an explicit group() when that level needs validators, state options, or validator messages:

const profile = form({
address: group({
city: field(''),
country: field(''),
}, {
disabled: () => !canEditAddress(),
}),
});

Groups and forms always have non-null object values. Use a field containing an object when the object itself must be nullable. Use an explicit nested form() only when that branch owns an independent submission action.

An empty form is valid, enabled, writable, visible, untouched, and pristine by default and has the value {}, unless a form-level validator or state option changes that result.

πŸ“š Arrays​

Use array() for a dynamic collection. Its first argument is a node template cloned for every item:

import { array, field, form } from '@ngblocks/form-nodes';

const myForm = form({
people: array({
name: field(''),
age: field(18),
}, {
initialValue: [{ name: 'Mark', age: 50 }],
}),
});

myForm.people(); // [{ name: 'Mark', age: 50 }]
myForm.people[0]?.name(); // 'Mark'

Use a field template when each item is a primitive value:

const myForm = form({
tags: array(field(''), ['angular', 'signals']),
});

myForm.tags(); // ['angular', 'signals']
myForm.tags[0]?.set('typescript');

Arrays always expose an array value. Passing null or undefined to set() or reset(value) clears the array.

βš™οΈ Node options​

Fields, forms, and arrays accept options for validators, debounce, and initial or reactive state:

const account = form({
email: field(''),
loginName: field(''),
}, {
disabled: () => !permissions().canEdit,
hidden: () => !featureFlags().account,
readonly: false,
debounce: 250,
});

The closest configured node provides inherited disabled, readonly, hidden, and debounce behavior to its descendants. A descendant can define its own option to override the inherited debounce or add another state cause.

πŸ“ Compile-time value safety​

TypeScript recursively infers the complete form value from the node tree. set() and reset(value) require a complete value, while patch() accepts partial object branches with known keys. Supplied arrays are complete collections, including every item's set value; omit an array property to preserve it.

profile.set({
name: 'Ada',
address: { city: 'London', country: 'UK' },
});

profile.patch({
address: { city: 'Zurich' },
});

Incorrect value types, missing complete-value properties, and unknown patch keys are compile-time errors. Validators receive the inferred value type too. Unknown runtime keys entering through unsafe casts or untyped external data are ignored, with warnings in development mode.

When named controls genuinely enter or leave at runtime, use add(), direct property access, and remove() on a form or group. Initially declared children remain fixed and precisely typed; runtime names are DynamicNode | undefined, exposing the state and operations common to every node kind. See Dynamic object children.

πŸ”Œ Using nodes outside Angular​

Node creation, synchronous validation, state transitions, and explicitly triggered asynchronous validation work without dependency injection:

const counter = field(0);

counter.update(value => (value ?? 0) + 1);
console.log(counter()); // 1

Provide an Angular Injector only when you want its DestroyRef to own asynchronous validation cleanup deterministically.

🧩 Declaring a form in a component​

In an Angular application, the form is commonly a component property:

import { Component } from '@angular/core';
import { field, FormNodeDirective, form } from '@ngblocks/form-nodes';

@Component({
selector: 'app-profile-editor',
imports: [FormNodeDirective],
template: `
<label>
Name
<input [formNode]="form.name" />
</label>

<label>
Hair color
<input [formNode]="form.hairColor" />
</label>

<p>Current name: {{ form.name() }}</p>
<p>Current hair color: {{ form.hairColor() }}</p>
`,
})
export class ProfileEditor {
form = form({
name: field(''),
hairColor: field(''),
});
}

⚑ Declarations inside computed​

You can construct a form with nested groups and arrays inside Angular's computed(). Signals read while declaring initial values, normalizing children, running item factories, or reading configuration getters remain dependencies. Editing the resulting nodes does not itself reconstruct the form.

import { computed, signal } from '@angular/core';
import { array, field, form, required } from '@ngblocks/form-nodes';

const initialName = signal('Marco');
const profile = computed(() => {
return form({
details: { name: field(initialName(), [required]) },
contacts: array({
email: field(''),
}, {
initialValue: [{ email: 'marco@example.com' }],
}),
});
});

const first = profile();
first.details.name(); // 'Marco'
first.contacts[0]!.email(); // 'marco@example.com'

if (first.details.name() !== 'Marco' || first.contacts[0]!.email() !== 'marco@example.com' || !first.valid()) {
throw new Error('The computed declaration should initialize nested values and validation.');
}

first.details.name.set('Lia');
first.markAsTouched();
if (profile() !== first || profile().details.name() !== 'Lia' || !first.touched()) {
throw new Error('Editing the constructed tree should preserve its identity and interaction state.');
}

initialName.set('Noa');
const second = profile();
second.details.name(); // 'Noa'
if (second === first || second.details.name() !== 'Noa' || second.touched()) {
throw new Error('Changing a declaration input should construct a fresh tree with fresh state.');
}
if (first.details.name() !== 'Lia' || second.contacts[0]!.email.parent() !== second.contacts[0]) {
throw new Error('Each constructed tree should retain its own values and parent links.');
}

When a declaration dependency changes, the example creates a fresh tree with its declared initial values and fresh interaction state. Use a stable form with reactive options or validators when configuration should change while retaining current edits. Construct fresh children in the computed when each evaluation is intended to produce an independent tree.

πŸ”Œ Current structural boundaries​

Initially declared object children are fixed, while explicitly added runtime children can later be removed. Use array() when equivalent items form an ordered collection that can also be reordered. The library does not currently generate form trees from JSON schema definitions.

See the complete form example for a larger Angular component combining nested forms, dynamic arrays, validation, state, and submission.