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 template | Explicit equivalent | Inferred node value | Prefer an explicit primitive when⦠|
|---|---|---|---|
name: '' | name: field('') | string | null | The field needs validators, options, or a generic |
age: 0 | age: field(0) | number | null | The field must be strict or configured |
active: false | active: field(false) | boolean | null | The field needs configuration |
birthday: new Date() | birthday: field(new Date()) | Date | null | The date needs field configuration |
empty: null or undefined | empty: field(null) or field(undefined) | unknown | The future type is known: use field<T>() |
roles: ['admin'] | roles: field(['admin']) | string[] | null | Items need nodes: use array(field('')) |
address: { city: '' } | address: group({ city: field('') }) | { city: string | null } | The branch needs validators or options: use group() |
company: classInstance | company: field(classInstance) | Company | null | Making the atomic boundary explicit improves readability |
company: { ...data } | company: group({ ... }) | Object assembled from child values | The object is one atomic value: use field(company) |
child: existingNode | Unchanged | Existing node value | Neverβexplicit nodes are preserved |
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.