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.
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.
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 with | Details |
|---|---|---|
| Create or configure a form | form(...), FormOptions | Signatures and options |
| Read its value or navigate children | myForm(), direct children, children | Properties and methods |
| Replace, derive, patch, or reset values | set(), update(), patch(), reset(), resetToInitial() | Method reference |
| Add, find, or remove runtime children | add(), get(), remove() | Dynamic children |
| Inspect or replace validation | errors(), allErrors(), valid(), setValidators() | Validation properties |
| Manage touched and dirty state | markAsTouched(), markAsDirty(), reset() | Interaction properties |
| Manage disabled, readonly, or hidden state | disable(), markAsReadonly(), hide() | Availability properties |
| Commit or focus bound controls | flush(), focus() | Control and submission properties |
| Run a configured action | submit(), submitting() | Submission methods |
| Handle a child/API name collision | $api | API 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<unknown>; 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.
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β
| Option | Accepted value | Purpose |
|---|---|---|
configure | (api) => void | Configure this instance once with its typed, collision-safe API. |
validators | Validator, validator array, null, or undefined | Validates the complete object value. Child validators continue to run independently. |
equal | 'shallow', 'deep', or (previous, next) => boolean | Retains equivalent exposed aggregate values; defaults to Object.is. |
validatorMessages | Message catalog or reactive catalog function | Overrides built-in validator messages for this subtree. |
debounce | Milliseconds, 'blur', or cancelable asynchronous function | Provides the default control-value debounce inherited by descendants. |
hidden | Boolean or reactive function | Sets or reactively derives hidden state for the complete subtree. |
disabled | Boolean, reason string, or reactive function | Sets or reactively derives disabled state for the complete subtree. |
readonly | Boolean or reactive function | Sets or reactively derives readonly state for the complete subtree. |
injector | Angular Injector | Explicitly owns injector-dependent work such as asynchronous validation watchers. |
inheritInjector | Boolean; defaults to true | Allows an injector-less nested form to use the nearest ancestor injector. |
adoptBindingInjector | Boolean; defaults to true | Allows 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) => void | Handles 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:
| Value | Submission 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.
| Member | Description |
|---|---|
| Value and tree | |
myForm() | Returns the current committed object value. This is the preferred value-reading form. |
myForm.child | Returns a named child node with its precise inferred type. |
children | Stable 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. |
$api | Guaranteed 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.
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.
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.
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.