Skip to main content

form()

For the exported FormNode model type and its generic counterpart, see the Node types reference.

form() creates a typed object tree that owns a submission workflow. Its initial children are fixed and precisely inferred; named children can also be attached and detached explicitly at runtime. Use groups or nested object shorthand for ordinary structural branches.

Not sure which node shape fits a value? See Choosing a primitive.

Safe outside Angular injection contexts

form() can be safely created and used outside an Angular injection context. Value and tree operations, state, submission invoked directly, synchronous validation, and asynchronous validation all continue to work. When an injector is available, its DestroyRef provides deterministic cleanup; without one, Form Nodes uses weak ownership so an unreachable form tree can be garbage-collected.

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

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

Nodes also support Angular WritableSignal<T> utilities. asReadonly() returns a stable live readonly value signal; use .$api when a child name shadows an operation. See writable signal interoperability.

Validator input and results​

See the validator argument and result contract for this primitive's positional and options.validators signatures. Callbacks accept the fully typed node context and return ValidationResult or ComposableValidationResult<TValue, TNode> at runtime: no error, messages, errors with string/numeric kinds, or synchronous validator compositions.

Declaration return inference

The TypeScript callback return is intentionally any so self-referencing declarations compile. The node and context remain typed. Annotate the return with ValidationResult (or ComposableValidationResult for composition), or use the checked context-taking validator() helper when you want result checking. Numeric error kinds are exposed as strings.

🧭 API map​

I want to…Start withDetails
Create or configure a formform(...), FormOptionsSignatures and options
Read its value or navigate childrenmyForm(), direct children, childrenProperties and methods
Replace, derive, patch, or reset valuesset(), update(), patch(), reset(), resetToInitial()Method reference
Add, find, or remove runtime childrenadd(), get(), remove()Dynamic children
Inspect or replace validationerrors(), allErrors(), valid(), setValidators()Validation properties
Manage touched and dirty statemarkAsTouched(), markAsDirty(), reset()Interaction properties
Manage disabled, readonly, or hidden statedisable(), markAsReadonly(), hide()Availability properties
Commit or focus bound controlsflush(), focus()Control and submission properties
Run a configured actionsubmit(), submitting()Submission methods
Handle a child/API name collision$apiAPI properties

Inline validators receive ctx.node() and ctx.field() typed as this primitive, preserving its value type and any declared children or array items. Inline validator() and asyncValidator() helpers retain that inference when their generics are omitted. See Inline node inference.

πŸ“ Signatures​

form(definitions, options?);
form(definitions, validators, options?);

β—† Extract the value type​

Use FormNodeValue<typeof myForm> to derive a reusable value type from a form instance, including nested objects, arrays, and field nullability:

type MyFormValue = FormNodeValue<typeof myForm>;

Import the type from @ngblocks/form-nodes. See the dedicated FormNodeValue reference for a complete example and the distinction from the child-map helper FormValue<TNodes>.

β—† Check a named value model​

Use satisfies FormValueContract<Model> when the complete value must conform to a named domain model. satisfies checks the callable form value and its value signal without replacing the type inferred from definitions:

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

type Profile = {
username: string | null;
age: number | null;
country: string;
};

const profile = form({
username: field(''),
age: field(0),
country: field.strict<string>('Switzerland'),
}) satisfies FormValueContract<Profile>;

const value: Profile = profile(); // { username: '', age: 0, country: 'Switzerland' }

void value;

Here profile() and profile.value() conform to Profile, while each child retains its inferred field type. field.strict<string>('Switzerland') gives country the non-nullable string type required by the model; ordinary field() declarations keep username and age nullable. An incompatible child value produces a TypeScript error at the satisfies expression. The same contract can check a group() because both primitives expose a callable aggregate value and a value signal. See the dedicated FormValueContract reference for nullability, incompatible-model, annotation, and structural-compatibility details.

Nested object definitions are normalized to groups. Use an explicit group() when that level needs validators, structural options, or validator messages. Use an explicit nested form() only when that branch needs an independent submission workflow.

β—† field() shorthand​

Values such as string, number, boolean, Date, null, and undefined, as well as arrays and class instances, are concise alternatives to calling field(). Nested plain object literals remain group shorthand:

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

type Company = {
companyId: number;
companyName: string;
};

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

const myForm = form({
name: '',
age: null,
siblings: 2,
birthday: new Date('1990-06-15T00:00:00.000Z'),
sister: undefined,
address: {
city: 'Zurich',
},
inlineCompany: {
companyId: 7,
companyName: 'Google',
},
company: defaultCompany,
roles: ['admin'],
recentCompanies: [defaultCompany],
});

myForm.name(); // string
myForm.age(); // unknown
myForm.siblings(); // number
myForm.birthday(); // Date
myForm.sister(); // unknown
myForm.address.city(); // string
myForm.company.companyId(); // number
myForm.roles(); // string[]
myForm.recentCompanies(); // Company[]
myForm.roles.nodeType() === 'field'; // true
myForm.recentCompanies.nodeType() === 'field'; // true

You can inspect how each shorthand was normalized with nodeType():

myForm.name.nodeType() === 'field'; // true
myForm.birthday.nodeType() === 'field'; // true
myForm.address.nodeType() === 'group'; // true
myForm.company.nodeType() === 'group'; // true
myForm.roles.nodeType() === 'field'; // true
myForm.recentCompanies.nodeType() === 'field'; // true

The equivalent explicit declarations are field(''), field(null), field(2), field(new Date()), and field(undefined). As with those calls, null and undefined infer FieldNode&lt;unknown&gt;; other values infer their widened value type plus null. Use an explicit field() when the child needs validators, state options, debounce, or a more specific generic than the initial value can provide.

See the declaration shorthand matrix for every normalization category, its explicit equivalent, and the cases that require field(), group(), or array().

Every array value, including an empty array, populated array, readonly tuple, or array of plain objects, becomes one FieldNode. Its interpretation never depends on its length or first item. To create a dynamic ArrayNode with independently addressable item nodes, declare array(...) explicitly. Use field([...]) when making the atomic array-value intent visually explicit or when the field needs configuration. An empty [] shorthand infers FieldNode<unknown[]> instead of the unusably narrow FieldNode<never[]>; use field<Item[]>([]) when the item type is known.

Declaration property rules

Definitions use own enumerable string-keyed data properties. Inherited and non-enumerable properties are ignored. Enumerable getters/setters, symbol keys, and the prototype-sensitive __proto__ key are rejected before any node is created. The error includes the complete path, for example profile.roles, and recommends an explicit primitive where applicable. String keys such as constructor and prototype remain valid children.

Every other value becomes an implicit field. This includes arrays, ordinary functions, and non-plain objects such as RegExp, URL, maps, sets, typed arrays, Temporal or Moment-like values, and custom class instances. Only plain objectsβ€”with Object.prototype or a null prototypeβ€”are interpreted as nested groups.

Primitive shorthand is therefore a convenient and unambiguous choice for values such as strings, numbers, booleans, and dates. Be more deliberate with object values: a plain object means a nested group, while a class instance or another non-plain object means one atomic field. When the object itself is the field value, prefer an explicit field(myObject) so that the intended node shape is obvious and remains stable if the value's construction or type annotation changes:

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

const myForm = form({
name: '',
company: field(defaultCompany),
});

If company is accidentally written as a plain object instead, it becomes a GroupNode, but it can still be bound as one value to a custom control with [formNode]. Aggregate nodes support control binding, so the component's value model receives the complete company object and updates are distributed to the group's children:

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

type CompanyValue = {
companyId: number | null;
companyName: string | null;
};

@Component({
selector: 'app-company-selector',
template: `{{ value().companyName }}`,
})
export class CompanySelector {
value = model<CompanyValue>({ companyId: null, companyName: null });
}

@Component({
imports: [FormNodeDirective, CompanySelector],
template: `<app-company-selector [formNode]="form.company" />`,
})
export class ProfileEditor {
form = form({
name: '',
company: { companyId: 23, companyName: 'Apple' },
});
}

This makes the binding usable, but it does not turn company into a FieldNode: it still exposes companyId and companyName child nodes and uses group validation and state aggregation. Prefer field(defaultCompany) when the company is conceptually one atomic field value.

Inline objects and values declared with an object type alias infer as groups. TypeScript interfaces do not guarantee the string-keyed definition contract: use { ...value } when an interface value should become a group, or field(value) when it should remain atomic. The explicit choice prevents TypeScript's interface/index-signature rules from disagreeing with the runtime prototype classification.

Type annotations cannot preserve runtime prototype information after a concrete value is widened. Use explicit field() at factory, deserialization, or other broadly typed boundaries when a value may be a class instance despite being annotated as a plain object shape. The runtime rule is deterministic, but field(myObject) avoids surprising results at these structurally typed boundaries.

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

βš™οΈ Options​

OptionAccepted valuePurpose
configure(api) => voidConfigure this instance once with its typed, collision-safe API.
validatorsValidator, validator array, null, or undefinedValidates the complete object value. Child validators continue to run independently.
equal'shallow', 'deep', or (previous, next) => booleanRetains equivalent exposed aggregate values; defaults to Object.is.
validatorMessagesMessage catalog or reactive catalog functionOverrides built-in validator messages for this subtree.
debounceMilliseconds, 'blur', or cancelable asynchronous functionProvides the default control-value debounce inherited by descendants.
hiddenBoolean or reactive functionSets or reactively derives hidden state for the complete subtree.
disabledBoolean, reason string, or reactive functionSets or reactively derives disabled state for the complete subtree.
readonlyBoolean or reactive functionSets or reactively derives readonly state for the complete subtree.
injectorAngular InjectorExplicitly owns injector-dependent work such as asynchronous validation watchers.
inheritInjectorBoolean; defaults to trueAllows an injector-less nested form to use the nearest ancestor injector.
adoptBindingInjectorBoolean; defaults to trueAllows direct [formNode] binding to provide a temporary host injector.
onSubmit(value, form) => error(s) | null | void | PromiseLike<…>Runs the action initiated by submit().
onSubmitBlocked(form) => voidHandles attempts blocked by validation.
submitWhen'valid' | 'not-invalid' | 'always'Controls the validation gate; defaults to 'not-invalid'.

Forms always have a non-null object value. To represent an optional object as a whole, use an object-valued field() instead.

βš™οΈ Option reference​

configure​

Signature: configure?: (api: TForm['$api']) => void

Synchronously configures each new instance with its callable, collision-safe API after its own structure is ready. The callback is untracked; validators installed inside it remain reactive. Fresh template clones run their own callback. Existing instances do not rerun it on reset or edits. Ancestors may not be attached yet. Return values are ignored.

See configuring nodes and sibling rules for an executable example, parent contracts, initialization order, and lifecycle details.

Each option includes its signature, default behavior, scope, and a complete example.

β—† Values and validation​

– equal​

Signature: equal?: 'shallow' | 'deep' | ((previous: TValue, next: TValue) => boolean)

Controls the exposed value read by the callable form, its value signal, value-dependent validators, submission action, and update callbacks. Children and bound controls keep their current committed values even when the form retains an equivalent previous snapshot. Public parents compose the exposed child values; internal debounce invalidation remains independent.

See Aggregate value equality for the complete executable example, operation contract, lazy evaluation, and comparator behavior.

– validators​

Signature: validators?: ValidatorSource<FormValue, FormNode<TNodes>>

Assigns one validator, several validators, or a reactive validator source to the complete form value. Validators declared by descendants remain independent.

const credentials = form({
password: field(''),
confirmation: field(''),
}, {
validators: ({ value }) => value().password === value().confirmation
? null
: { kind: 'passwordMismatch' },
});

credentials.invalid(); // false

– validatorMessages​

Signature: validatorMessages?: ValidatorMessages | (() => ValidatorMessages | undefined)

Overrides built-in validator messages for this form and its descendants. It may be a static catalog or a reactive function; a message configured directly on a validator still takes precedence.

const profile = form({
username: field('', [required]),
}, {
validatorMessages: {
required: 'Enter a username.',
},
});

profile.allErrors()[0]?.message; // 'Enter a username.'

– debounce​

Signature: debounce?: number | 'blur' | ((abortSignal: AbortSignal) => void | PromiseLike<void>)

Provides the default control-value debounce inherited by descendants. A descendant's local debounce option overrides it. Omit it to commit control-originated values immediately.

const search = form({
query: field(''),
category: field('all'),
}, {
debounce: 300,
});

β—† Availability​

– hidden​

Signature: hidden?: boolean | (() => boolean)

Sets or reactively derives hidden state for the complete form subtree. It defaults to false.

const businessDetails = form({
companyName: field(''),
}, {
hidden: () => accountType() !== 'business',
});

– disabled​

Signature: disabled?: boolean | string | (() => boolean | string)

Sets or reactively derives disabled state for the complete subtree. A string also becomes a message in disabledReasons(). It defaults to false.

const profile = form({
username: field(''),
}, {
disabled: 'Profile is locked',
});

profile.disabledReasons()[0]?.message; // 'Profile is locked'

– readonly​

Signature: readonly?: boolean | (() => boolean)

Sets or reactively derives readonly state for the complete form subtree. It defaults to false.

const profile = form({
username: field(''),
}, {
readonly: () => profileArchived(),
});

β—† Injector ownership​

– injector​

Signature: injector?: Injector

Provides an explicit lifecycle owner for injector-dependent work such as asynchronous validation.

const injector = inject(Injector);
const profile = form({
username: field(''),
}, {
injector,
});

– inheritInjector​

Signature: inheritInjector?: boolean

Allows an otherwise injector-less nested form to use the nearest ancestor injector. It defaults to true; false creates an inheritance boundary.

const profile = form({
username: field(''),
}, {
inheritInjector: false,
});

– adoptBindingInjector​

Signature: adoptBindingInjector?: boolean

Allows an otherwise injector-less form to adopt the injector of a directly bound [formNode] host while that binding exists. It defaults to true.

const profile = form({
username: field(''),
}, {
adoptBindingInjector: false,
});

β—† Submission​

– onSubmit, onSubmitBlocked, and submitWhen​

Signatures:

onSubmit?(value: TValue, form: TForm): void | null | ValidationErrorWithOptionalTargetNode<AnyNode> | readonly ValidationErrorWithOptionalTargetNode<AnyNode>[] | PromiseLike<void | null | ValidationErrorWithOptionalTargetNode<AnyNode> | readonly ValidationErrorWithOptionalTargetNode<AnyNode>[]>;
onSubmitBlocked?(form: TForm): void;
submitWhen?: 'valid' | 'not-invalid' | 'always';

Return one error or a readonly error array to reject submitted data. Omitted targets belong to this form; explicit targets must belong to its captured subtree. null, undefined, and an empty array indicate success. Errors clear on edits/reset and before retrying; stale responses are ignored. See Server rejection errors for ownership, retry, and lifecycle rules.

Configures submit(). onSubmit(value, form) runs when the current validation policy allows submission. onSubmitBlocked(form) runs when validation blocks it, including pending validation with submitWhen: 'valid'. Pending validation is not awaited. Concurrent attempts and missing actions return false without invoking onSubmitBlocked.

const profile = form({
username: field('', [required]),
}, {
onSubmit: async value => saveProfile(value),
onSubmitBlocked: invalidForm => invalidForm.focus(),
submitWhen: 'not-invalid',
});

submitWhen accepts:

ValueSubmission policy
'not-invalid'Default. Blocks known errors but permits submission while validity is only unknown.
'valid'Requires valid(); both errors and pending validation block submission.
'always'Runs the action regardless of validation status.

πŸ“– Properties and methods​

A form is a callable aggregate-value reader with named child properties, reactive signals, structural operations, submission, and the shared node state API. Signal properties must be called to read their current value; children is a stable readonly map rather than a signal.

MemberDescription
Value and tree
myForm()Returns the current committed object value. This is the preferred value-reading form.
myForm.childReturns a named child node with its precise inferred type.
childrenStable readonly map of every current named child.
value()Current committed aggregate value. Equivalent to calling the form directly.
value.committed()Latest committed data before configured equality checks.
value.committed.set(value)Complete immediate write, equivalent to set().
value.control()Complete value from a control bound directly to the form.
value.control.set(value)Receives control input with debounce and dirty tracking.
nodeType()Returns the literal 'form'.
form()This explicit form workflow.
root()Complete structural root; a root form returns itself.
parent()Direct parent node, or null at the root or after detachment.
path()Property path from the root; array indexes are string segments.
keyInParent()Property name or array index in the parent, or null at the root.
$apiGuaranteed collision-safe form API.
Dynamic children
add(key, definition)Attaches and returns one runtime child with its exact inferred node type.
add(definitions)Atomically attaches and returns several runtime children.
get(key)Returns a current child by runtime key, or undefined.
remove(key)Detaches and returns a dynamically added child, or undefined.
Value updates
set(value)Assigns a complete object value without marking nodes dirty.
update(updater)Derives and assigns a complete value from the current value.
patch(value)Recursively assigns only supplied child branches.
reset(value?)Optionally assigns a value, then recursively clears interaction state.
resetToInitial()Restores captured initial values and clears subtree interaction state.
Validation
validators()Current normalized validators owned by the form.
setValidators(source)Replaces the form validator source and revalidates.
errors()Errors owned directly by this form, excluding descendants.
allErrors()Errors from this form and every current descendant.
getError(kind)First form-owned error with a kind, or undefined.
valid()Whether the complete form subtree is valid.
invalid()Whether the form or a current descendant is invalid.
required()Whether active metadata marks the form itself as required.
pending()Whether asynchronous validation is active in the subtree.
validationStatus()Aggregated 'valid', 'invalid', or 'unknown' phase.
Interaction
touched()Whether the form or a contributing descendant is touched.
untouched()Logical inverse of touched().
markAsTouched(options?)Marks the form and, by default, every descendant touched.
markAsUntouched()Clears only the form's own stored touched state.
dirty()Whether the form or a contributing descendant is dirty.
pristine()Logical inverse of dirty().
markAsDirty()Marks the form's own state dirty.
markAsPristine()Clears only the form's own dirty state.
Availability
disabled()Whether the form is disabled locally or by an ancestor.
disabledReasons()Active local and inherited disabled causes.
enabled()Logical inverse of disabled().
disable(message?)Disables the subtree and optionally records a reason.
enable()Clears the imperative disabled state.
readonly()Whether the form is readonly locally or through an ancestor.
writable()Logical inverse of readonly().
markAsReadonly()Marks the form subtree readonly.
markAsWritable()Clears the imperative readonly state.
hidden()Whether the form is hidden locally or through an ancestor.
visible()Logical inverse of hidden().
hide()Marks the form subtree hidden.
show()Clears the imperative hidden state.
Control and submission
debouncing()Whether the form or a descendant has pending control input.
flush()Commits pending control values throughout the subtree.
focus(options?)Focuses the first bound control in DOM order.
submitted()Whether this form has received a submit attempt since its last reset.
submitting()Whether this form or an ancestor is running submission.
submit()Runs the configured submission workflow and returns its outcome.

Every declared child name takes precedence over ordinary API and native callable member names. The reserved names $api cannot be used as child keys, so those access paths remain stable.

const profile = form({
reset: field('Not the reset method'),
});

profile.reset(); // 'Not the reset method'
profile.$api.reset();

See Tree navigation and API access for collision and generic-code patterns.

πŸ“– Property reference​

Each entry includes its consumer-facing signature, what it represents or returns, and a complete example. FormValue means the inferred committed object value, FormSet means the complete value accepted by set(), and FormPatch means the recursively partial value accepted by patch().

β—† Value and tree properties​

– Callable value​

Signature: (): FormValue

Calls the form as a signal and returns its current committed aggregate value. This is the recommended complete-value read.

const profile = form({
username: field('ada'),
active: field(true),
});

profile(); // { username: 'ada', active: true }

– Named child access​

Signature: readonly [childName]: ChildNode

Returns a named child node with the precise type inferred from its definition.

const profile = form({
username: field('ada'),
address: {
city: field('Zurich'),
},
});

profile.username(); // 'ada'
profile.address.city(); // 'Zurich'

– children​

Signature: readonly children: FormChildren

Returns the stable readonly map of current named child nodes, including dynamically added children.

const profile = form({
username: field('ada'),
});

profile.children.username(); // 'ada'

Direct access is preferred for initially declared children. Use get(key) for runtime keys. If a declared child is named children, use profile.$api.children for the map.

– value()​

Signature: value: NodeValueSignal<FormValue, FormSet>

Contains the current committed aggregate value.

const profile = form({
username: field('ada'),
});

profile.value(); // { username: 'ada' }

Prefer the equivalent callable form, profile(), for ordinary value reads.

– value.committed()​

Signature: value.committed: Signal<FormValue> & { set(value: FormSet): void }

Reads the latest committed data, bypassing configured equal checks on this node and its children. Pending debounce is still respected. Normal signal identity checks still apply. See the value views reference for an executable example.

– value.committed.set()​

Signature: value.committed.set(value: FormSet): void

Equivalent to set(value): commits immediately, cancels pending input, preserves dirty/touched state, and follows normal validation and parent propagation. Exposed reads still honor equal. See the setter example.

– value.control()​

Signature: value.control: Signal<FormValue>

Contains the complete value most recently received from a control bound directly to this form.

const profile = form({
username: field('ada'),
});

profile.value.control(); // { username: 'ada' }

Pending descendant control values are not aggregated into this signal; read each descendant's value.control() when that immediate buffered value is needed.

With aggregate equal, this control-facing signal can contain newer committed child values than the exposed form value even without a pending debounce. See Aggregate value equality.

– value.control.set()​

Signature: value.control.set(value: FormSet): void

Receives a complete value for a control bound to this node, marks this node dirty, and applies configured or inherited debounce. It does not mark touched or emit binding outputs by itself. Read the control setter example and propagation details.

– nodeType()​

Signature: nodeType(): 'form'

Returns the stable primitive discriminant for this node. If a child named nodeType shadows the direct method, use myForm.$api.nodeType().

const profile = form({
username: field('ada'),
});

profile.nodeType(); // 'form'

– form()​

Signature: form: Signal<FormNode>

Returns this explicit form because every form() owns a submission workflow boundary. Descendants return their nearest explicit form, so a nested form becomes the workflow owner for its subtree.

const profile = form({
username: field('ada'),
});

profile.form() === profile; // true

– root()​

Signature: root: Signal<RootNode>

Returns the complete structural root containing this form. A root or detached form returns itself; a nested form returns the outermost node in the complete tree.

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

checkout.payment.form() === checkout.payment; // true
checkout.payment.root() === checkout; // true

– parent()​

Signature: parent: Signal<ParentNode | null>

Returns the direct parent node, or null when the form is a root or has been detached.

const profile = form({
settings: form({
theme: field('system'),
}),
});

profile.settings.parent() === profile; // true

– path()​

Signature: path: Signal<readonly string[]>

Returns the property and array-index segments from the root to this form.

const profile = form({
settings: form({
theme: field('system'),
}),
});

profile.settings.path(); // ['settings']

– keyInParent()​

Signature: keyInParent: Signal<string | number | null>

Returns the property name or array index under which the form is stored, or null at the root.

const profile = form({
settings: form({
theme: field('system'),
}),
});

profile.settings.keyInParent(); // 'settings'

β—† API properties​

– $api​

Signature: $api: FormApi

Always exposes the complete form API, even when a child collides with an API member.

const profile = form({
api: field('public-profile-api'),
reset: field('reset label'),
});

profile.api(); // 'public-profile-api'
profile.reset(); // 'reset label'
profile.$api.reset();

β—† Validation properties​

– validators()​

Signature: validators: Signal<Validators<FormValue>> & { (options: { resolve?: boolean }): Validators<FormValue> }

Contains the normalized validators owned directly by the form, in declaration order.

const credentials = form({
password: field('secret'),
confirmation: field('secret'),
}, {
validators: credentialsMatch,
});

credentials.validators().length; // 1

– errors()​

Signature: errors: NodeErrorsSignal<TNode>

Reads own errors by default. Pass { descendants: true } to include every descendant, exactly as allErrors() does. { descendants: false }, {}, and no arguments read only own errors. TNode is this concrete node type.

The property remains an Angular Signal. Own reads preserve the concrete targetNode type; descendant reads use AnyNode because errors can belong to different node kinds. See error queries for an executable example.

Contains validation errors owned directly by the form and excludes descendant errors.

const credentials = form({
password: field('secret'),
confirmation: field('different'),
}, {
validators: credentialsMatch,
});

credentials.errors()[0]?.kind; // 'passwordMismatch'

– allErrors()​

Signature: allErrors: Signal<readonly ValidationError[]>

Shortcut for errors({ descendants: true }), returning the same cached array.

Contains errors from the form and every current descendant. Each error identifies its targetNode.

const profile = form({
username: field('', [required]),
});

profile.allErrors()[0]?.kind; // 'required'
profile.errors(); // []

– valid()​

Signature: valid: Signal<boolean>

Returns whether the form and every current descendant have completed validation without errors.

const profile = form({
username: field('ada', [required]),
});

profile.valid(); // true

– invalid()​

Signature: invalid: Signal<boolean>

Returns whether the form or any current descendant contributes a validation error.

const profile = form({
username: field('', [required]),
});

profile.invalid(); // true

– required()​

Signature: required: Signal<boolean>

Returns whether active validation metadata marks the form itself as required. Forms still have a non-null object value.

const profile = form({
username: field('ada'),
}, {
validators: required,
});

profile.required(); // true

– pending()​

Signature: pending: Signal<boolean>

Returns whether asynchronous validation is active on the form or a current descendant.

const profile = form({
username: field('ada'),
}, {
validators: asyncValidator(async () => {
await checkProfile();
return null;
}),
});

profile.pending(); // true while checkProfile() is running

– validationStatus()​

Signature: validationStatus: Signal<'valid' | 'invalid' | 'unknown'>

Returns the aggregate validation phase for the form subtree.

const profile = form({
username: field('', [required]),
});

profile.validationStatus(); // 'invalid'

'unknown' means asynchronous validation is pending and no available error currently makes the subtree invalid.

β—† Interaction properties​

– touched()​

Signature: touched: Signal<boolean>

Returns whether the form or any contributing current descendant has been marked touched.

const profile = form({
username: field('ada'),
});

profile.username.markAsTouched();
profile.touched(); // true

– untouched()​

Signature: untouched: Signal<boolean>

Returns the logical inverse of touched().

const profile = form({
username: field('ada'),
});

profile.untouched(); // true

– dirty()​

Signature: dirty: Signal<boolean>

Returns whether the form's own state or any contributing descendant reports user-modified state.

const profile = form({
username: field('ada'),
});

profile.username.markAsDirty();
profile.dirty(); // true

– pristine()​

Signature: pristine: Signal<boolean>

Returns the logical inverse of dirty().

const profile = form({
username: field('ada'),
});

profile.pristine(); // true

β—† Availability properties​

– disabled()​

Signature: disabled: Signal<boolean>

Returns whether the form is effectively disabled by its own state, configuration, or an ancestor.

const profile = form({
username: field('ada'),
}, {
disabled: true,
});

profile.disabled(); // true

– disabledReasons()​

Signature: disabledReasons: Signal<readonly DisabledReason[]>

Contains all active local and inherited causes of the disabled state, including each source node and optional message.

const profile = form({
username: field('ada'),
}, {
disabled: 'Profile is locked',
});

profile.disabledReasons()[0]?.message; // 'Profile is locked'
profile.username.disabled(); // true

– enabled()​

Signature: enabled: Signal<boolean>

Returns the logical inverse of disabled().

const profile = form({
username: field('ada'),
});

profile.enabled(); // true

– readonly()​

Signature: readonly: Signal<boolean>

Returns whether the form is effectively readonly through its own state or an ancestor.

const profile = form({
username: field('ada'),
}, {
readonly: true,
});

profile.readonly(); // true

– writable()​

Signature: writable: Signal<boolean>

Returns the logical inverse of readonly() and indicates whether a control bound directly to the form may commit value changes.

const profile = form({
username: field('ada'),
});

profile.writable(); // true

– hidden()​

Signature: hidden: Signal<boolean>

Returns whether the form is effectively hidden through its own state or an ancestor.

const profile = form({
username: field('ada'),
}, {
hidden: true,
});

profile.hidden(); // true

– visible()​

Signature: visible: Signal<boolean>

Returns the logical inverse of hidden().

const profile = form({
username: field('ada'),
});

profile.visible(); // true

β—† Control and submission properties​

– debouncing()​

Signature: debouncing: Signal<boolean>

Returns whether the form itself or a current descendant has control input awaiting commit.

const search = form({
query: field('', { debounce: 300 }),
});

search.debouncing(); // false before a bound control has a pending value

– submitted()​

Signature: submitted: Signal<boolean>

Records an attempt on this specific form, even when invalid, already submitting, or missing an action. This readonly signal starts false and changes synchronously before submission guards. It does not mean success and is independent of the temporary submitting() state.

Value edits and action completion preserve it. All form reset methods clear it, including resets propagated from an ancestor; a field reset does not. Nested forms own independent histories. A pending action finishing after a reset does not reactivate it. Use $api.submitted() if a child is named submitted. Read the complete rules.

submission-history.ts
import { computed } from '@angular/core';
import { field, form, required } from '@ngblocks/form-nodes';

const profile = form({
name: field('', [required]),
}, {
onSubmit: async () => { await Promise.resolve(); },
});
const showErrors = computed(() => profile.name.invalid() && (profile.name.touched() || profile.submitted()));

profile.submitted(); // false
await profile.submit();
profile.submitted(); // true: validation blocked the attempt
profile.submitting(); // false: no action is running
if (!profile.submitted() || !showErrors() || profile.submitting()) {
throw new Error('An invalid submission must expose its attempt without running an action.');
}

profile.name.reset();
profile.name.touched(); // false
showErrors(); // true: resetting one field preserves its form's submission history
if (!showErrors()) throw new Error('A field reset must not clear its owner form history.');

profile.name.set('Ada');
await profile.submit();
profile.submitted(); // true: successful completion preserves history
if (!profile.submitted() || profile.submitting()) throw new Error('Completed submission history must persist.');

profile.resetToInitial();
profile.submitted(); // false
showErrors(); // false
if (profile.submitted() || showErrors()) throw new Error('A form reset must clear submission history.');

– submitting()​

Signature: submitting: Signal<boolean>

Returns whether this form or an ancestor form is currently running its submission action.

const profile = form({
username: field('ada'),
}, {
onSubmit: async () => saveProfile(),
});

profile.submitting(); // true while saveProfile() is running
profile.username.submitting(); // true while saveProfile() is running

πŸ“– Method reference​

Each entry includes its consumer-facing signature, behavior, and return value.

β—† Dynamic children​

– add()​

Signatures: add(key: string, definition): AddedNode Β· add(definitions): AddedNodes

Attaches one child or several children at runtime. A single definition returns its exact attached node; an object returns an exact keyed map. Plain nested objects become group() nodes. Concise values, including arrays, use the same field() shorthand as the initial declaration.

const profile = form({
username: field('ada'),
});

const age = profile.add('age', field(36));
age(); // 36
profile.get('age') === age; // true

const added = profile.add({
nickname: field('countess'),
preferences: {
theme: field('dark'),
},
});

added.nickname(); // 'countess'
added.preferences.theme(); // 'dark'
profile.get('preferences') === added.preferences; // true

Keys must be new, definitions must be detached, and $api is reserved. The object form is atomic: validation completes before any supplied child is attached. Keep the returned node for its exact type, or retrieve it later with get(). Array values become fields; declare array(...) explicitly for a dynamic node collection. Wrap a plain application object with field(value) when it should remain one atomic value.

– get()​

Signature: get(key: string): DynamicNode | undefined

Returns a current child by runtime key. Dynamically added children deliberately do not become direct properties, so misspelled names fail TypeScript and Angular template checking.

important

Use profile.name only for a child included in the original form() declaration. After profile.add('age', ...), use the returned node or profile.get('age'). Neither profile.age nor profile['age'] is supported.

const profile = form({ username: field('ada') });
profile.add('age', field(36));

profile.get('age')?.value(); // 36
profile.get('missing'); // undefined

– remove()​

Signature: remove(key: string): DynamicNode | undefined

Detaches and returns a dynamically added child. An absent key returns undefined; attempting to remove an initially declared child throws.

const profile = form({
username: field('ada'),
});
const nickname = profile.add('nickname', field('countess'));

const removed = profile.remove('nickname');
removed === nickname; // true
removed?.parent(); // null
profile.remove('missing'); // undefined

See Dynamic object children for value typing and collision behavior.

β—† Update values and reset state​

– set()​

Signature: set(value: FormSet): void

Immediately assigns a complete value to every supplied child without marking nodes dirty. The public type requires the complete initially declared form shape.

const profile = form({
username: field('ada'),
active: field(true),
});

profile.set({
username: 'grace',
active: false,
});

profile(); // { username: 'grace', active: false }

Unknown runtime keys are ignored with a warning in development mode.

– update()​

Signature: update(updater: (value: FormValue) => FormSet): void

Passes the current aggregate value to a callback and immediately assigns its complete result.

const profile = form({
username: field(' ada '),
active: field(true),
});

profile.update(value => ({
...value,
username: value.username?.trim() ?? null,
}));

profile.username(); // 'ada'

– patch()​

Signature: patch(value: FormPatch): void

Supplied arrays are complete collection values: their length and order replace the previous collection, and every item requires its complete set value. Matching nodes are reused by index or trackBy; empty arrays, null, and undefined clear the collection. Omit an array property to leave it unchanged. For partial row edits, call that row's patch(). See array patching.

Recursively updates supplied child branches and leaves omitted branches unchanged. It does not mark nodes dirty.

const profile = form({
username: field('ada'),
address: {
city: field('London'),
country: field('UK'),
},
});

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

profile.address(); // { city: 'Zurich', country: 'UK' }

Unknown runtime keys are ignored with a warning in development mode.

– reset()​

Signatures: reset(): void Β· reset(value: FormSet): void

Recursively cancels pending control input and clears touched and dirty state. Without an argument it keeps all current values; with a value it assigns the complete value first.

const profile = form({
username: field('ada'),
});

profile.markAsTouched();
profile.username.markAsDirty();
profile.reset({ username: 'grace' });

profile(); // { username: 'grace' }
profile.touched(); // false
profile.pristine(); // true

β—† Validation and interaction​

– setValidators()​

Signature: setValidators(validators: ValidatorSource<FormValue, FormNode<TNodes>>): void

Replaces validators owned by the form and immediately evaluates its current aggregate value. Child validators are unchanged.

const credentials = form({
password: field('secret'),
confirmation: field('different'),
});

credentials.setValidators(credentialsMatch);
credentials.invalid(); // true

– getError()​

getError() and hasError() suggest built-in error kinds such as 'required', 'minLength', and 'email', plus kinds registered through ValidationErrorMap. Custom string literals and dynamic string values are still accepted. Suggestions list known kinds regardless of installed validators; they do not indicate that an error is currently present. The same suggestions are available through $api, and getError() keeps its kind-specific payload and target-node types.

Signature: getError(kind: string): ValidationError | undefined

Returns the first error owned directly by the form with the requested kind. Descendant errors are available through allErrors().

const credentials = form({
password: field('secret'),
confirmation: field('different'),
}, {
validators: credentialsMatch,
});

credentials.getError('passwordMismatch')?.kind; // 'passwordMismatch'

– markAsTouched()​

Signature: markAsTouched(options?: { skipDescendants?: boolean }): void

Marks the form and every current descendant touched and flushes their pending control input. Pass skipDescendants: true to mark and flush only the form itself.

const profile = form({
username: field('ada'),
});

profile.markAsTouched();
profile.username.touched(); // true

profile.reset();
profile.markAsTouched({ skipDescendants: true });
profile.username.touched(); // false

– markAsUntouched()​

Signature: markAsUntouched(): void

Clears only the form's own stored touched state. A touched descendant can keep aggregate touched() equal to true; use reset() to clear the complete subtree.

const profile = form({
username: field('ada'),
});

profile.markAsTouched({ skipDescendants: true });
profile.markAsUntouched();
profile.untouched(); // true

– markAsDirty()​

Signature: markAsDirty(): void

Marks the form's own state dirty. Programmatic value updates do not call this method automatically.

const profile = form({
username: field('ada'),
});

profile.markAsDirty();
profile.dirty(); // true

– markAsPristine()​

Signature: markAsPristine(): void

Clears only the form's own dirty state. A dirty descendant can keep aggregate dirty() equal to true.

const profile = form({
username: field('ada'),
});

profile.markAsDirty();
profile.markAsPristine();
profile.pristine(); // true

β—† Availability​

– disable()​

Signature: disable(message?: string): void

Disables the form subtree. An optional message records why it was disabled.

const profile = form({
username: field('ada'),
});

profile.disable('Profile is locked');
profile.disabled(); // true
profile.username.disabled(); // true

– enable()​

Signature: enable(): void

Clears the disabled state created by disable(). Configured or inherited reasons can still keep the form disabled.

const profile = form({
username: field('ada'),
});

profile.disable();
profile.enable();
profile.enabled(); // true

– markAsReadonly()​

Signature: markAsReadonly(): void

Marks the form subtree readonly, preventing bound controls from committing value changes.

const profile = form({
username: field('ada'),
});

profile.markAsReadonly();
profile.username.writable(); // false

– markAsWritable()​

Signature: markAsWritable(): void

Clears the readonly state created by markAsReadonly(). Other configured or inherited readonly state can still apply.

const profile = form({
username: field('ada'),
});

profile.markAsReadonly();
profile.markAsWritable();
profile.writable(); // true

– hide()​

Signature: hide(): void

Marks the form subtree hidden without changing its values.

const profile = form({
username: field('ada'),
});

profile.hide();
profile.username.visible(); // false

– show()​

Signature: show(): void

Clears the hidden state created by hide(). Other configured or inherited hidden state can still apply.

const profile = form({
username: field('ada'),
});

profile.hide();
profile.show();
profile.visible(); // true

β—† Control methods​

– flush()​

Signature: flush(): void

Immediately commits pending control values throughout the form subtree. It is a no-op when no control is debouncing.

const search = form({
query: field('', { debounce: 300 }),
});

search.query.value.control.set('angular');
search.flush();
search.query(); // 'angular'
search.debouncing(); // false

– focus()​

Signature: focus(options?: FocusOptions): void

Focuses the first bound UI control in the form subtree in DOM order. A control bound directly to the form takes precedence over descendant bindings. Standard FocusOptions are forwarded.

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

@Component({
imports: [FormNodeDirective],
template: `
<input [formNode]="form.username" />
<input [formNode]="form.email" />
`,
})
export class ProfileComponent {
form = form({
username: field(''),
email: field(''),
});

focusFirstControl() {
this.form.focus({ preventScroll: true });
}
}

β—† Submission methods​

– submit()​

Signature: submit(): Promise<boolean>

Records submitted() immediately, then follows the existing concurrency guard, subtree interaction, validation policy, and onSubmit action when allowed.

const profile = form({
username: field('', [required]),
}, {
onSubmit: async value => saveProfile(value),
onSubmitBlocked: invalidForm => invalidForm.focus(),
});

const submitted = await profile.submit();

The promise resolves to true after the action completes successfully. It resolves to false when the action returns a nonempty error result, validation blocks submission, submission is already running, or no action is configured. If the action throws or rejects, submit() rejects with that error and still clears submitting().

See Submission, Dynamic object children, and the shared Node API.

🌳 Iterate over immediate children​

forEachChild(callback) calls callback(child, key) once per declared immediate child and returns void. It excludes children added with add(). A child can be a field, group, form, or array; iteration does not recurse. The callback receives the union of declared child types, preserving their concrete node and parent types, and a string key.

Pass { includeDynamic: true } as the second argument to visit both declared and added children. The callback then receives DynamicNode, without undefined. A runtime boolean also uses DynamicNode, since dynamic nodes may be included. Omitted options, {}, and an explicit { includeDynamic: false } preserve the declared-child union.

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

const profile = form({
contact: {
name: field('Marco'),
email: field('marco@example.com'),
},
active: field(true),
});

const keys: string[] = [];
profile.contact.forEachChild((child, key) => {
keys.push(key);
child.markAsTouched();
});

profile.contact.name.touched(); // true
profile.contact.email.touched(); // true
profile.active.touched(); // false

if (keys.join(',') !== 'name,email' || !profile.contact.name.touched()
|| !profile.contact.email.touched() || profile.active.touched()) {
throw new Error('forEachChild must visit only the immediate children of the selected group.');
}

const rootKeys: string[] = [];
profile.forEachChild((_child, key) => rootKeys.push(key));

if (rootKeys.join(',') !== 'contact,active') {
throw new Error('Form iteration must visit the group node without traversing its descendants.');
}

const score = profile.contact.add('score', field(5));
const declaredKeys: string[] = [];
profile.contact.forEachChild((_child, key) => declaredKeys.push(key));

if (declaredKeys.join(',') !== 'name,email') {
throw new Error('Default iteration must exclude dynamically added children.');
}

const allKeys: string[] = [];
profile.contact.forEachChild((child, key) => {
// child: DynamicNode, including both declared and dynamically added nodes
allKeys.push(key);
child.markAsTouched();
}, { includeDynamic: true });

score.touched(); // true

if (allKeys.join(',') !== 'name,email,score' || !score.touched()) {
throw new Error('Opted-in iteration must include dynamically added children.');
}
import { field, form } from '@ngblocks/form-nodes';

const profile = form({ username: field(''), age: field(2) });
profile.forEachChild(child => {
// child is the union of the username and age node types.
const value: string | number | null = child();
console.log(value);
child.markAsTouched();
// set('') would be rejected: a numeric field cannot accept a string.
});

const contact = form({ username: field(''), email: field('') });
contact.forEachChild(child => child.set('')); // Both fields accept strings.

A mixed union allows reads of all its value types; a write must be accepted by every possible child. A string cannot be assigned to a union that includes a numeric field.

Iteration uses a snapshot in Object.entries() order: integer-like keys come first in numeric order, followed by other string keys in insertion order. Children added during a callback can be visited on the next call with dynamic inclusion enabled. Children removed during a callback remain in the current snapshot. An exception from a callback propagates immediately and stops the remaining callbacks.

Inside computed() or effect(), iteration tracks additions and removals, plus any signals read by the callback. It does not read child values automatically. The iterator itself does not change values, validation, or interaction state; operations called by the callback retain their usual behavior, including descendant propagation.

If a child is named forEachChild, use $api.forEachChild() to access the operation.

πŸ“ Runtime child map and enumeration​

children exposes every runtime child. Declared properties such as children.name retain their exact types. Unknown names such as children.nonExisting and children[key] use DynamicNode; with TypeScript's noUncheckedIndexedAccess, these lookups also include undefined, allowing children.nonExisting?.value(). Missing names return undefined at runtime. get(key) always includes undefined in its return type, independently of that compiler option.

Object.values(node.children) includes dynamically added nodes, so its inferred element type includes DynamicNode and may retain concrete declared-node alternatives. Use forEachChild() for the exact union of declared child types. Pass { includeDynamic: true } to that method for all-child iteration with DynamicNode callbacks.

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

const profile = form({
contact: {
name: field('Marco'),
age: field(30),
},
});

profile.contact.children.name(); // 'Marco'
profile.contact.children.nonExisting?.value(); // undefined

// Default iteration preserves the declared name-or-age node union.
const declaredValues: (string | number | null)[] = [];
profile.contact.forEachChild(child => declaredValues.push(child()));
if (declaredValues.length !== 2 || declaredValues[0] !== 'Marco' || declaredValues[1] !== 30) {
throw new Error('Declared-child iteration must retain its concrete value union.');
}

const active = profile.contact.add('active', field(true));
profile.contact.children.active?.value(); // true

// Runtime enumeration includes added children; its element type includes DynamicNode.
const children = Object.values(profile.contact.children);
if (children.length !== 3 || profile.contact.children.active !== active) {
throw new Error('The runtime child map must expose added nodes.');
}

profile.contact.remove('active');
profile.contact.children.active?.value(); // undefined
if (profile.contact.children.active !== undefined || Object.values(profile.contact.children).length !== 2) {
throw new Error('Removed nodes must disappear from runtime lookups and enumeration.');
}

🚨 Query errors and registered validators​

hasError(kind: string): boolean checks the node's own current errors(), like getError(kind) !== undefined. It does not search descendants or allErrors(). Synchronous, asynchronous, and bound-control errors are included when present in errors().

hasValidator(validator, options?: { resolve?: boolean }): boolean checks the directly registered validator list by function identity, including async validators. Retain a factory's returned function to query it later. A registered validator remains present while passing, disabled, or skipped by a condition. By default, returned compositions are not expanded. With { resolve: true }, this checks the same leaf references as validators({ resolve: true }), reusing synchronous validation evaluation. This can execute synchronous validators; async validators are listed without starting their work. External control validators and descendants are not searched. See Inspect resolved validators for conditional branches, successful leaves, interaction-state suppression, and exceptions.

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

const minimumNameLength = minLength(3);
const profile = form({ name: field('', [required, minimumNameLength]) });
const missingName = computed(() => profile.name.hasError('required'));

profile.name.hasError('required'); // true
profile.hasError('required'); // false: the error belongs to the child
profile.name.hasValidator(required); // true
profile.name.hasValidator(minimumNameLength); // true
profile.name.hasValidator(minLength(3)); // false: a different function instance

if (!missingName() || profile.hasError('required') || !profile.name.hasValidator(required)
|| !profile.name.hasValidator(minimumNameLength) || profile.name.hasValidator(minLength(3))) {
throw new Error('Queries must distinguish own errors from registered validator identities.');
}

profile.name.set('Marco');
missingName(); // false
profile.name.hasValidator(required); // true: still registered, now passing

if (missingName() || !profile.name.hasValidator(required)) {
throw new Error('Passing validation must clear the error without removing the validator.');
}

profile.name.setValidators([]);
profile.name.hasValidator(required); // false

if (profile.name.hasValidator(required)) {
throw new Error('Validator queries must reflect replacement of the registered validators.');
}

Both queries participate in reactive tracking when read inside computed() or effect() and memoize their boolean result by argument. hasError() follows error changes; hasValidator() follows setValidators() and, with resolution enabled, dependencies read by synchronous validators. The default registration query does not execute validators. For a child named hasError or hasValidator, use the parent's $api to call that operation.

🌳 Empty declarations as dynamic records​

With form({}) or group({}), the empty declaration acts as a dynamic record for child access: Object.values(node.children) is DynamicNode[]. Use forEachChild(callback, { includeDynamic: true }) to visit added children with a DynamicNode callback. Without that option there are no declared children to visit, and the callback's child type is DynamicNode, so expressions such as child.set('') compile. This also applies to nested empty groups and forms. Nonempty declarations retain their concrete child union for default iteration.

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

const profile = form({
answers: group({}),
});
const name = profile.answers.add('name', field('Marco'));
const age = profile.answers.add('age', field(18));

// The default callback is typed as DynamicNode but does not visit added children.
profile.answers.forEachChild(child => child.set(''));
if (name() !== 'Marco' || age() !== 18) {
throw new Error('Default iteration must exclude dynamically added children.');
}

// Empty declarations expose dynamic children; iteration opts in explicitly.
const children = Object.values(profile.answers.children); // DynamicNode[]
profile.answers.forEachChild(child => {
// child: DynamicNode, without undefined
child.markAsTouched();
}, { includeDynamic: true });

name(); // 'Marco'
age(); // 18
profile.answers.get('missing'); // undefined

if (children.length !== 2 || children[0] !== name || children[1] !== age
|| !name.touched() || !age.touched() || profile.answers.get('missing') !== undefined) {
throw new Error('An empty declaration must support dynamic child enumeration and lookup.');
}

const record = form({});
record.add('active', field(true));
if (Object.values(record.children).length !== 1 || record.get('active')?.() !== true) {
throw new Error('Empty forms must support the same dynamic record pattern.');
}

get(key) remains DynamicNode | undefined because a requested key may be missing. Keep the result of add() when you need the exact added node type. Enumeration types do not change the form's statically inferred value shape or expand its direct child properties. This behavior is chosen from the declaration's type, not from the current number of runtime children.

↩️ resetToInitial()​

Signature: resetToInitial(): void

Restores captured initial values and clears dirty/touched state in this subtree. It cancels pending control input, synchronizes rendered controls, and retains current validators and availability configuration. It does not emit control-originated value outputs. Unlike reset(), it replaces values; unlike reset(value), it needs no value argument and does not use the last loaded record.

Object branches keep their current schema and restore each existing field to its own baseline. Arrays restore their initial values, count, and order through ordinary index or trackBy reconciliation. Factories can run to reconstruct missing nodes; restored data uses captured values.

See Reset and restore initial values for executable examples, server-loaded records, nested arrays, dynamically added fields, snapshot boundaries, validation, and native reset buttons.

Empty declaration​

form(): FormNode<{}> creates the same initially empty object as form({}). It starts valid, untouched, and pristine. Add children dynamically with add(); the returned child retains its inferred type. The original variable's static child keys do not grow after an addition, so retain the returned child or use the documented dynamic lookup API.

For validators or options, keep the explicit empty definition: form({}, options). The zero-argument overload has no generic parameters and does not invent a typed child schema. Each call creates independent nodes and state. createFormPrimitives().form() supports the same empty declaration while retaining its configured defaults.

empty-primitives.ts
import { array, field, form, group, createFormPrimitives } from '@ngblocks/form-nodes';

const profile = form();
const details = group();
const values = array();
profile(); // {}
details(); // {}
values(); // []

const name = profile.add('name', field('Ada'));
details.add('city', field('Zurich'));
values.push('Ada');
values.push(23);
values.push();
values(); // ['Ada', 23, null]
if (name() !== 'Ada' || !profile.valid()) throw new Error('Empty forms must support normal dynamic additions.');
if (values.length() !== 3 || values.at(2)!() !== null) throw new Error('Default array items must be unknown-valued fields initialized to null.');

values.resetToInitial();
values(); // []
if (values.length() !== 0) throw new Error('Resetting initial values must restore the initially empty array.');

const configured = createFormPrimitives({ nullable: false });
const configuredValues = configured.array();
if (configuredValues.push()() !== null) throw new Error('An unspecified default item must retain the null placeholder.');

Value change callback​

onValueChange?(value: TValue, node: TForm): void;

Add onValueChange to the options to react synchronously to committed public value changes. The callback skips initialization, respects equal and control debounce, and receives the typed node. Aggregate operations notify after their children are updated. It runs without dependency tracking or an injection-context requirement and does not wait for asynchronous validation. See value change callbacks for the executable example, reset and array behavior, callback ordering, and error handling.

Subscribe after creation​

onValueChange(callback: (value: FormValue<TNodes>, node: TNode) => void, options?: { injector?: Injector; debounce?: number }): () => void;

Here TNode is the inferred type of this form instance. Call the instance method to register independent listeners after construction; use $api.onValueChange() if a child hides the method. It returns an idempotent cancellation function and emits no initial value. The explicit injector, otherwise the registration context, owns the listener; node ownership provides a fallback and also ends the subscription when destroyed. Observation remains available without DI.

Pass { debounce: 300 } to deliver only the latest change after 300 ms of silence for this listener. Omission or 0 keeps synchronous delivery. Values, validation, and interaction state update normally; this delay applies to programmatic writes and committed control edits. Unsubscribing or owner destruction cancels pending delivery. The delay must be finite and non-negative; otherwise registration throws RangeError. Delayed callback errors are thrown from the timer callback. See subscription debounce for a component example, reset behavior, and how it combines with control debounce.

See instance subscriptions for typed examples, initializing from component inputs in ngOnInit() to patch initial values before listening, automatic cleanup for owner precedence and rebinding, and the callback section above for equality, debounce, and notification timing.