group()
For the exported GroupNode model type and its generic counterpart, see the
Node types reference.
group() creates a typed object aggregate. It provides named children, value aggregation,
validation, state propagation, configuration, and the common node operations. It deliberately has
no onSubmit option and no submit() method.
Use FormNodeValue<typeof myGroup> to extract a group's value type.
Plain nested objects in form(), group(), and object templates in array() are shorthand for
groups. Prefer shorthand until a branch needs its own options or validators.
Use a plain object for an ordinary structural branch. Form Nodes normalizes it to the same group
node that an explicit group() call would create:
const profileWithShorthand = form({
name: field(''),
address: {
city: field(''),
country: field(''),
},
});
const profileWithExplicitGroup = form({
name: field(''),
address: group({
city: field(''),
country: field(''),
}),
});
Both address properties expose the same group API, child types, aggregate value, validation, and
state propagation. Use explicit group() when that branch needs its own options or validators,
such as disabled, readonly, hidden, debounce, or validatorMessages, or when the group is
declared as a standalone root.
A group can also be the root of a node tree. form() is not required when the model needs aggregate
structure and state but does not own a submission workflow.
Use group() as the root when a component needs a complete form tree but does not need Form Nodes'
submission workflow. Values, validation, interaction state, availability, reset, and control
binding work normally; the component can invoke its own action explicitly.
import { Component } from '@angular/core';
import { field, FormNodeDirective, group } from '@ngblocks/form-nodes';
declare function loadProducts(filters: { query: string | null; category: string | null }): void;
@Component({
selector: 'app-product-filters',
imports: [FormNodeDirective],
template: `
<form [formNode]="filters">
<input [formNode]="filters.query" placeholder="Search products" />
<select [formNode]="filters.category">
<option value="all">All categories</option>
<option value="books">Books</option>
<option value="music">Music</option>
</select>
<button type="button" (click)="applyFilters()">Apply filters</button>
<button type="reset">Reset</button>
</form>
`,
})
export class ProductFilters {
filters = group({
query: field(''),
category: field('all'),
});
applyFilters() {
loadProducts(this.filters());
}
}
The native reset delegates to filters.reset(). Because a group has no onSubmit option or
submit() method, use form() instead when the root should own an action, invalid-submission
handling, concurrent-submission protection, or submitting() state of its own.
group() can be safely created and used outside an Angular injection context. Value and tree
operations, state, 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 group tree can be garbage-collected.
import { field, form, group, required } from '@ngblocks/form-nodes';
const myForm = form({
displayName: field(''),
address: group({
city: field('', [required]),
country: field(''),
}, {
disabled: () => !canEditAddress(),
}),
});
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 an object branch | group(...), GroupOptions | Signatures and options |
| Decide between a group and submission boundary | group(), form() | Group or form |
| Read its value or navigate children | myGroup(), direct children, children | Properties and methods |
| Add, find, or remove runtime children | add(), get(), remove() | Dynamic children |
| Replace, derive, patch, or reset values | set(), update(), patch(), reset(), resetToInitial() | Method reference |
| Inspect or replace validation | errors(), allErrors(), valid(), setValidators() | Validation properties |
| Manage interaction or availability | State signals and marker methods | Interaction and availability |
| Commit or focus bound controls | flush(), focus() | Control methods |
| Observe an ancestor submission | submitting() | Control and workflow 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
group(definitions, options?);
group(definitions, validators, options?);
Use FormValueContract<Model> with satisfies to check a group against
a named aggregate value while preserving its inferred child-node types.
◆ field() shorthand
Values such as string, number, boolean, Date, null, and undefined, as well as arrays
and class instances, can stand in for field() when defining a group:
const address = group({
city: 'Zurich',
postcode: 8000,
});
address.city(); // 'Zurich'
address.postcode(); // 8000
This shorthand is especially convenient and unambiguous for strings, numbers, booleans, dates,
and arrays used as one control value. Every array becomes a FieldNode, regardless of whether it is
empty or what its items contain. Declare array(...) explicitly when the items need their own
nodes, validation, interaction state, or structural operations. An empty [] shorthand widens to
unknown[]; use field<Item[]>([]) when the eventual item type is known.
See the declaration shorthand matrix for the explicit equivalent and inferred value of every shorthand category.
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. Errors include the complete path from the
group() root. String keys such as constructor and prototype remain valid children.
Take more care with object values. Plain objects are interpreted as nested groups, whereas
functions, class instances, and other non-plain objects become atomic fields. If an object is
intended to be one field value, prefer an explicit field(myObject). This makes the intended node
shape clear and avoids surprises if its construction or type annotation changes:
const defaultCompany = { companyId: 23, companyName: 'Apple' };
const profile = group({
name: '',
company: field(defaultCompany),
});
If a plain company object is inferred as a GroupNode, [formNode] binding still works. Aggregate
nodes can bind to a custom control as one complete value; changes from the control are distributed
to the group's child nodes:
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' },
});
}
The bound value has the expected company object shape, but the node remains a group with
companyId and companyName children, group validation, and aggregated state. Use
field(defaultCompany) when the object should instead be one atomic field.
The runtime classification is deterministic, but TypeScript's structural types cannot always
retain whether an annotated object originated as a plain object or a class instance. Explicit
field() is the safest choice at factory, deserialization, and other broadly typed boundaries. It
is also required when the child needs validators or field options.
⚙️ Options
GroupOptions accepts the object-node configuration shared with form(), except for submission.
A group can inherit submitting() from an ancestor form but cannot initiate submission itself.
| 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 remain independent. |
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 group to use the nearest ancestor injector. |
adoptBindingInjector | Boolean; defaults to true | Allows direct [formNode] binding to provide a temporary host injector. |
Start with a named validator for a reusable object rule. Use an inline callback for a small rule specific to one group:
const filters = group({
query: field(''),
category: field(''),
}, {
validators: ({ value }) => value().query || value().category
? null
: { kind: 'emptyFilters', message: 'Enter a query or choose a category.' },
});
Group validators receive the complete object. Put a validator on a child field when the rule only concerns that child's value.
⚙️ Option reference
configure
Signature: configure?: (api: TGroup['$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 public group snapshot used by callable/value reads, value-dependent validators,
update callbacks, and public parent values. Child writes and control synchronization keep using
current committed values. See Aggregate value equality
for the executable example and full contract shared with form().
– validators
Signature: validators?: ValidatorSource<GroupValue, GroupNode<TNodes>>
Assigns one validator, several validators, or a reactive validator source to the complete group value. Validators declared by descendants continue to run independently.
const dateRange = group({
start: field<Date>(),
end: field<Date>(),
}, {
validators: validDateRange,
});
– validatorMessages
Signature: validatorMessages?: ValidatorMessages | (() => ValidatorMessages | undefined)
Overrides built-in validator messages for the group subtree. It may be a static catalog or a reactive function; validator-local messages still take precedence.
const address = group({
city: field('', [required]),
country: field(''),
}, {
validatorMessages: {
required: 'Enter a city.',
},
});
address.allErrors()[0]?.message; // 'Enter a city.'
– debounce
Signature: debounce?: number | 'blur' | ((abortSignal: AbortSignal) => void | PromiseLike<void>)
Provides the default control-value debounce inherited by descendants. A descendant's local option overrides it. Omit it to commit control-originated values immediately.
const address = group({
city: field(''),
country: field(''),
}, {
debounce: 300,
});
◆ Availability
– hidden
Signature: hidden?: boolean | (() => boolean)
Sets or reactively derives hidden state for the complete group subtree. It defaults to false.
const shippingAddress = group({
city: field(''),
}, {
hidden: () => useBillingAddress(),
});
– 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 address = group({
city: field(''),
}, {
disabled: 'Address is managed by your organization',
});
address.disabledReasons()[0]?.message; // 'Address is managed by your organization'
– readonly
Signature: readonly?: boolean | (() => boolean)
Sets or reactively derives readonly state for the complete group subtree. It defaults to false.
const identity = group({
legalName: field(''),
}, {
readonly: () => identityVerified(),
});
◆ Injector ownership
– injector
Signature: injector?: Injector
Provides an explicit lifecycle owner for injector-dependent work such as asynchronous validation.
const injector = inject(Injector);
const address = group({
city: field(''),
}, {
injector,
});
– inheritInjector
Signature: inheritInjector?: boolean
Allows an otherwise injector-less group to use the nearest ancestor injector. It defaults to
true; false creates an inheritance boundary.
const address = group({
city: field(''),
}, {
inheritInjector: false,
});
– adoptBindingInjector
Signature: adoptBindingInjector?: boolean
Allows an otherwise injector-less group to adopt the injector of a directly bound [formNode] host
while that binding exists. It defaults to true.
const address = group({
city: field(''),
}, {
adoptBindingInjector: false,
});
🌳 Value and children
The group is callable, and direct child access is preferred:
myForm.address(); // { city: '', country: '' }
myForm.address.city(); // ''
myForm.address.children.city; // the same field node, through the explicit child map
Groups always expose a non-null object value. Model an atomic or nullable object with field()
instead. Use array() when the structure has a dynamic number of independently addressable items.
📖 Properties and methods
A group is a callable aggregate-value reader with named child properties, reactive signals,
structural operations, and the shared node state API. Signal properties must be called to read
their value; children is a stable readonly map rather than a signal.
| Member | Description |
|---|---|
| Value and tree | |
myGroup() | Returns the current committed object value. This is the preferred value-reading form. |
myGroup.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 group 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 group. |
value.control.set(value) | Receives control input with debounce and dirty tracking. |
nodeType() | Returns the literal 'group'. |
form() | Nearest explicit form workflow, or null when none owns the group. |
root() | Complete structural root; a root group 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 group 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 group. |
setValidators(source) | Replaces the group validator source and revalidates. |
errors() | Errors owned directly by this group, excluding descendants. |
allErrors() | Errors from this group and every current descendant. |
getError(kind) | First group-owned error with a kind, or undefined. |
valid() | Whether the complete group subtree is valid. |
invalid() | Whether the group or a current descendant is invalid. |
required() | Whether active metadata marks the group itself as required. |
pending() | Whether asynchronous validation is active in the subtree. |
validationStatus() | Aggregated 'valid', 'invalid', or 'unknown' phase. |
| Interaction | |
touched() | Whether the group or a contributing descendant is touched. |
untouched() | Logical inverse of touched(). |
markAsTouched(options?) | Marks the group and, by default, every descendant touched. |
markAsUntouched() | Clears only the group's own stored touched state. |
dirty() | Whether the group or a contributing descendant is dirty. |
pristine() | Logical inverse of dirty(). |
markAsDirty() | Marks the group's own state dirty. |
markAsPristine() | Clears only the group's own dirty state. |
| Availability | |
disabled() | Whether the group 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 group is readonly locally or through an ancestor. |
writable() | Logical inverse of readonly(). |
markAsReadonly() | Marks the group subtree readonly. |
markAsWritable() | Clears the imperative readonly state. |
hidden() | Whether the group is hidden locally or through an ancestor. |
visible() | Logical inverse of hidden(). |
hide() | Marks the group subtree hidden. |
show() | Clears the imperative hidden state. |
| Control and workflow | |
debouncing() | Whether the group 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. |
submitting() | Whether an ancestor form is running its submission action. |
Every declared child name takes precedence over ordinary API and native callable member names.
$api is reserved, so that access path remains stable.
const details = group({
reset: field('Not the reset method'),
});
details.reset(); // 'Not the reset method'
details.$api.reset();
See Tree navigation and API access for collision and generic-code patterns.
🌳 Group or form?
Use group() for structure and form() for a submission boundary:
const checkout = form({
shippingAddress: {
city: field(''), // shorthand Group
},
payment: form({
cardNumber: field(''),
}, {
onSubmit: savePayment,
}),
}, {
onSubmit: placeOrder,
});
A group may be bound to a native <form [formNode]> without breaking its controls. Native submit
is prevented and marks and flushes the group tree, while native reset delegates to group.reset().
Because a group has no submission action, use form() when the element must execute application
submission behavior.
🌳 Group or plain object?
A normal object containing standalone fields is also valid:
const filters = {
query: field(''),
category: field(''),
};
Use that minimal structure when the nodes are genuinely independent. Choose a root group() when
you need an aggregate callable value, parent and path relationships, recursive updates and reset,
aggregate validity and interaction state, inherited availability or debounce, or validators for the
complete object. The plain object itself has none of those node capabilities.
🌳 Dynamic children
Groups support the same explicit add(), get(), and remove() operations as forms:
const filters = group({ query: field('') });
const category = filters.add('category', field('all'));
category(); // 'all'
filters.get('category') === category; // true
filters.remove('category');
Initial children cannot be removed. Added children inherit the group's tree state and injector, and detached children remain usable independently. See Dynamic object children for the complete contract.
📖 Property reference
Each entry includes its consumer-facing signature, what it represents or returns, and a complete
example. GroupValue means the inferred committed object value, GroupSet means the complete
value accepted by set(), and GroupPatch means the recursively partial value accepted by
patch().
◆ Value and tree properties
– Callable value
Signature: (): GroupValue
Calls the group as a signal and returns its current committed aggregate value. This is the recommended complete-value read.
const address = group({
city: field('Zurich'),
country: field('CH'),
});
address(); // { city: 'Zurich', country: 'CH' }
– Named child access
Signature: readonly [childName]: ChildNode
Returns a named child node with the precise type inferred from its definition.
const address = group({
city: field('Zurich'),
coordinates: {
latitude: field(47.3769),
},
});
address.city(); // 'Zurich'
address.coordinates.latitude(); // 47.3769
– children
Signature: readonly children: GroupChildren
Returns the stable readonly map of current named child nodes, including dynamically added children.
const address = group({
city: field('Zurich'),
});
address.children.city(); // 'Zurich'
Direct access is preferred for initially declared children. Use get(key) for
runtime keys. If a child is named children, use address.$api.children.
– value()
Signature: value: NodeValueSignal<GroupValue, GroupSet>
Contains the current committed aggregate value.
const address = group({
city: field('Zurich'),
});
address.value(); // { city: 'Zurich' }
Prefer the equivalent callable form, address(), for ordinary value reads.
– value.committed()
Signature: value.committed: Signal<GroupValue> & { set(value: GroupSet): 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: GroupSet): 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<GroupValue>
Contains the complete value most recently received from a control bound directly to the group.
const address = group({
city: field('Zurich'),
});
address.value.control(); // { city: 'Zurich' }
Pending descendant control values are not aggregated into this signal. Read a descendant's
value.control() when its immediate buffered value is needed.
– value.control.set()
Signature: value.control.set(value: GroupSet): 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(): 'group'
Returns the stable primitive discriminant for this node. If a child named nodeType shadows the
direct method, use myGroup.$api.nodeType().
const address = group({
city: field('Zurich'),
});
address.nodeType(); // 'group'
– form()
Signature: form: Signal<FormNode | null>
Returns the nearest explicit form() containing the group. A standalone or detached group returns
null because it provides structure without owning a form workflow.
const address = group({
city: field('Zurich'),
});
address.form(); // null
– root()
Signature: root: Signal<RootNode>
Returns the complete structural root containing the group. A standalone or detached group returns itself.
const address = group({
city: field('Zurich'),
});
address.root() === address; // true
– parent()
Signature: parent: Signal<ParentNode | null>
Returns the direct parent node, or null when the group is a root or has been detached.
const profile = form({
address: group({
city: field('Zurich'),
}),
});
profile.address.parent() === profile; // true
– path()
Signature: path: Signal<readonly string[]>
Returns the property and array-index segments from the root to this group.
const profile = form({
address: group({
city: field('Zurich'),
}),
});
profile.address.path(); // ['address']
– keyInParent()
Signature: keyInParent: Signal<string | number | null>
Returns the property name or array index under which the group is stored, or null at the root.
const profile = form({
address: group({
city: field('Zurich'),
}),
});
profile.address.keyInParent(); // 'address'
◆ API properties
– $api
Signature: $api: GroupApi
Always exposes the complete group API, even when a child collides with an API member.
const details = group({
api: field('public-api'),
reset: field('reset label'),
});
details.$api(); // 'public-api'
details.reset(); // 'reset label'
details.$api.reset();
◆ Validation properties
– validators()
Signature: validators: Signal<Validators<GroupValue>> & { (options: { resolve?: boolean }): Validators<GroupValue> }
Contains the normalized validators owned directly by the group, in declaration order.
const address = group({
city: field('Zurich'),
country: field('CH'),
}, {
validators: supportedAddress,
});
address.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 group and excludes descendant errors.
const filters = group({
query: field(''),
category: field(''),
}, {
validators: nonEmptyFilters,
});
filters.errors()[0]?.kind; // 'emptyFilters'
– allErrors()
Signature: allErrors: Signal<readonly ValidationError[]>
Shortcut for errors({ descendants: true }), returning the same cached array.
Contains errors from the group and every current descendant. Each error identifies its
targetNode.
const address = group({
city: field('', [required]),
});
address.allErrors()[0]?.kind; // 'required'
address.errors(); // []
– valid()
Signature: valid: Signal<boolean>
Returns whether the group and every current descendant have completed validation without errors.
const address = group({
city: field('Zurich', [required]),
});
address.valid(); // true
– invalid()
Signature: invalid: Signal<boolean>
Returns whether the group or any current descendant contributes a validation error.
const address = group({
city: field('', [required]),
});
address.invalid(); // true
– required()
Signature: required: Signal<boolean>
Returns whether active validation metadata marks the group itself as required. Groups still have a non-null object value.
const address = group({
city: field('Zurich'),
}, {
validators: required,
});
address.required(); // true
– pending()
Signature: pending: Signal<boolean>
Returns whether asynchronous validation is active on the group or a current descendant.
const address = group({
city: field('Zurich'),
}, {
validators: asyncValidator(async () => {
await checkAddress();
return null;
}),
});
address.pending(); // true while checkAddress() is running
– validationStatus()
Signature: validationStatus: Signal<'valid' | 'invalid' | 'unknown'>
Returns the aggregate validation phase for the group subtree.
const address = group({
city: field('', [required]),
});
address.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 group or any contributing current descendant has been marked touched.
const address = group({
city: field('Zurich'),
});
address.city.markAsTouched();
address.touched(); // true
– untouched()
Signature: untouched: Signal<boolean>
Returns the logical inverse of touched().
const address = group({
city: field('Zurich'),
});
address.untouched(); // true
– dirty()
Signature: dirty: Signal<boolean>
Returns whether the group's own state or any contributing descendant reports user-modified state.
const address = group({
city: field('Zurich'),
});
address.city.markAsDirty();
address.dirty(); // true
– pristine()
Signature: pristine: Signal<boolean>
Returns the logical inverse of dirty().
const address = group({
city: field('Zurich'),
});
address.pristine(); // true
◆ Availability properties
– disabled()
Signature: disabled: Signal<boolean>
Returns whether the group is effectively disabled by its own state, configuration, or an ancestor.
const address = group({
city: field('Zurich'),
}, {
disabled: true,
});
address.disabled(); // true
– disabledReasons()
Signature: disabledReasons: Signal<readonly DisabledReason[]>
Contains all active local and inherited disabled causes, including each source node and optional message.
const address = group({
city: field('Zurich'),
}, {
disabled: 'Address is locked',
});
address.disabledReasons()[0]?.message; // 'Address is locked'
address.city.disabled(); // true
– enabled()
Signature: enabled: Signal<boolean>
Returns the logical inverse of disabled().
const address = group({
city: field('Zurich'),
});
address.enabled(); // true
– readonly()
Signature: readonly: Signal<boolean>
Returns whether the group is effectively readonly through its own state or an ancestor.
const address = group({
city: field('Zurich'),
}, {
readonly: true,
});
address.readonly(); // true
– writable()
Signature: writable: Signal<boolean>
Returns the logical inverse of readonly() and indicates whether a control bound directly to the
group may commit value changes.
const address = group({
city: field('Zurich'),
});
address.writable(); // true
– hidden()
Signature: hidden: Signal<boolean>
Returns whether the group is effectively hidden through its own state or an ancestor.
const address = group({
city: field('Zurich'),
}, {
hidden: true,
});
address.hidden(); // true
– visible()
Signature: visible: Signal<boolean>
Returns the logical inverse of hidden().
const address = group({
city: field('Zurich'),
});
address.visible(); // true
◆ Control and workflow properties
– debouncing()
Signature: debouncing: Signal<boolean>
Returns whether the group itself or a current descendant has control input awaiting commit.
const address = group({
city: field('', { debounce: 300 }),
});
address.debouncing(); // false before a bound control has a pending value
– submitting()
Signature: submitting: Signal<boolean>
Returns whether an ancestor form is currently running its submission action. A group cannot start submission itself.
const profile = form({
address: group({
city: field('Zurich'),
}),
}, {
onSubmit: async () => saveProfile(),
});
profile.address.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 filters = group({
query: field(''),
});
const category = filters.add('category', field('all'));
category(); // 'all'
filters.get('category') === category; // true
const added = filters.add({
sort: field('relevance'),
range: {
minimum: field(0),
maximum: field(100),
},
});
added.sort(); // 'relevance'
added.range.maximum(); // 100
filters.get('range') === added.range; // true
Keys must be new, definitions must be detached, and $api is reserved. The object
form validates every supplied definition before attaching any child. 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 are not direct properties, so misspelled names fail TypeScript and Angular template checking.
Use filters.query only for a child included in the original group() declaration. After
filters.add('category', ...), use the returned node or filters.get('category'). Neither filters.category nor filters['category'] is supported.
const filters = group({ query: field('') });
filters.add('category', field('all'));
filters.get('category')?.value(); // 'all'
filters.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 filters = group({
query: field(''),
});
const category = filters.add('category', field('all'));
const removed = filters.remove('category');
removed === category; // true
removed?.parent(); // null
filters.remove('missing'); // undefined
◆ Update values and reset state
– set()
Signature: set(value: GroupSet): void
Immediately assigns a complete value to every supplied child without marking nodes dirty. The public type requires the complete initially declared group shape.
const address = group({
city: field('Zurich'),
country: field('CH'),
});
address.set({
city: 'London',
country: 'UK',
});
address(); // { city: 'London', country: 'UK' }
Unknown runtime keys are ignored with a warning in development mode.
– update()
Signature: update(updater: (value: GroupValue) => GroupSet): void
Passes the current aggregate value to a callback and immediately assigns its complete result.
const address = group({
city: field(' Zurich '),
country: field('CH'),
});
address.update(value => ({
...value,
city: value.city?.trim() ?? null,
}));
address.city(); // 'Zurich'
– patch()
Signature: patch(value: GroupPatch): 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 address = group({
city: field('London'),
country: field('UK'),
coordinates: {
latitude: field(51.5072),
longitude: field(-0.1276),
},
});
address.patch({
coordinates: {
latitude: 47.3769,
},
});
address.coordinates(); // { latitude: 47.3769, longitude: -0.1276 }
Unknown runtime keys are ignored with a warning in development mode.
– reset()
Signatures: reset(): void · reset(value: GroupSet): 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 address = group({
city: field('Zurich'),
});
address.markAsTouched();
address.city.markAsDirty();
address.reset({ city: 'London' });
address(); // { city: 'London' }
address.touched(); // false
address.pristine(); // true
◆ Validation and interaction
– setValidators()
Signature: setValidators(validators: ValidatorSource<GroupValue, GroupNode<TNodes>>): void
Replaces validators owned by the group and immediately evaluates its current aggregate value. Child validators are unchanged.
const filters = group({
query: field(''),
category: field(''),
});
filters.setValidators(nonEmptyFilters);
filters.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 group with the requested kind. Descendant errors are
available through allErrors().
const filters = group({
query: field(''),
category: field(''),
}, {
validators: nonEmptyFilters,
});
filters.getError('emptyFilters')?.kind; // 'emptyFilters'
– markAsTouched()
Signature: markAsTouched(options?: { skipDescendants?: boolean }): void
Marks the group and every current descendant touched and flushes their pending control input. Pass
skipDescendants: true to mark and flush only the group itself.
const address = group({
city: field('Zurich'),
});
address.markAsTouched();
address.city.touched(); // true
address.reset();
address.markAsTouched({ skipDescendants: true });
address.city.touched(); // false
– markAsUntouched()
Signature: markAsUntouched(): void
Clears only the group's own stored touched state. A touched descendant can keep aggregate
touched() equal to true; use reset() to clear the complete subtree.
const address = group({
city: field('Zurich'),
});
address.markAsTouched({ skipDescendants: true });
address.markAsUntouched();
address.untouched(); // true
– markAsDirty()
Signature: markAsDirty(): void
Marks the group's own state dirty. Programmatic value updates do not call this method automatically.
const address = group({
city: field('Zurich'),
});
address.markAsDirty();
address.dirty(); // true
– markAsPristine()
Signature: markAsPristine(): void
Clears only the group's own dirty state. A dirty descendant can keep aggregate dirty() equal to
true.
const address = group({
city: field('Zurich'),
});
address.markAsDirty();
address.markAsPristine();
address.pristine(); // true
◆ Availability
– disable()
Signature: disable(message?: string): void
Disables the group subtree. An optional message records why it was disabled.
const address = group({
city: field('Zurich'),
});
address.disable('Address is locked');
address.disabled(); // true
address.city.disabled(); // true
– enable()
Signature: enable(): void
Clears the disabled state created by disable(). Configured or inherited reasons can still keep
the group disabled.
const address = group({
city: field('Zurich'),
});
address.disable();
address.enable();
address.enabled(); // true
– markAsReadonly()
Signature: markAsReadonly(): void
Marks the group subtree readonly, preventing bound controls from committing value changes.
const address = group({
city: field('Zurich'),
});
address.markAsReadonly();
address.city.writable(); // false
– markAsWritable()
Signature: markAsWritable(): void
Clears the readonly state created by markAsReadonly(). Other configured or inherited readonly
state can still apply.
const address = group({
city: field('Zurich'),
});
address.markAsReadonly();
address.markAsWritable();
address.writable(); // true
– hide()
Signature: hide(): void
Marks the group subtree hidden without changing its values.
const address = group({
city: field('Zurich'),
});
address.hide();
address.city.visible(); // false
– show()
Signature: show(): void
Clears the hidden state created by hide(). Other configured or inherited hidden state can still
apply.
const address = group({
city: field('Zurich'),
});
address.hide();
address.show();
address.visible(); // true
◆ Control methods
– flush()
Signature: flush(): void
Immediately commits pending control values throughout the group subtree. It is a no-op when no control is debouncing.
const address = group({
city: field('', { debounce: 300 }),
});
address.city.value.control.set('Zurich');
address.flush();
address.city(); // 'Zurich'
address.debouncing(); // false
– focus()
Signature: focus(options?: FocusOptions): void
Focuses the first bound UI control in the group subtree in DOM order. A control bound directly to
the group takes precedence over descendant bindings. Standard FocusOptions are forwarded.
import { Component } from '@angular/core';
import { field, FormNodeDirective, group } from '@ngblocks/form-nodes';
@Component({
imports: [FormNodeDirective],
template: `
<input [formNode]="address.city" />
<input [formNode]="address.country" />
`,
})
export class AddressComponent {
address = group({
city: field(''),
country: field(''),
});
focusFirstControl() {
this.address.focus({ preventScroll: true });
}
}
See Dynamic object children, Tree navigation and API access, 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
group(): GroupNode<{}> creates the same initially empty object as group({}).
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: group({}, 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().group() 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: TGroup): 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: GroupValue<TNodes>, node: TNode) => void, options?: { injector?: Injector; debounce?: number }): () => void;
Here TNode is the inferred type of this group 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, automatic cleanup for owner precedence and rebinding, and the callback section above for equality, debounce, and notification timing.