Skip to main content

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.

Prefer object shorthand when the group needs no configuration

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.

A group can be the root model of a component

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.

Safe outside Angular injection contexts

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.

Declaration return inference

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

🧭 API map

I want to…Start withDetails
Create or configure an object branchgroup(...), GroupOptionsSignatures and options
Decide between a group and submission boundarygroup(), form()Group or form
Read its value or navigate childrenmyGroup(), direct children, childrenProperties and methods
Add, find, or remove runtime childrenadd(), get(), remove()Dynamic children
Replace, derive, patch, or reset valuesset(), update(), patch(), reset(), resetToInitial()Method reference
Inspect or replace validationerrors(), allErrors(), valid(), setValidators()Validation properties
Manage interaction or availabilityState signals and marker methodsInteraction and availability
Commit or focus bound controlsflush(), focus()Control methods
Observe an ancestor submissionsubmitting()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.

Declaration property rules

Definitions use own enumerable string-keyed data properties. Inherited and non-enumerable properties are ignored. Enumerable getters/setters, symbol keys, and the prototype-sensitive __proto__ key are rejected before any node is created. 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.

OptionAccepted valuePurpose
configure(api) => voidConfigure this instance once with its typed, collision-safe API.
validatorsValidator, validator array, null, or undefinedValidates the complete object value. Child validators remain independent.
equal'shallow', 'deep', or (previous, next) => booleanRetains equivalent exposed aggregate values; defaults to Object.is.
validatorMessagesMessage catalog or reactive catalog functionOverrides built-in validator messages for this subtree.
debounceMilliseconds, 'blur', or cancelable asynchronous functionProvides the default control-value debounce inherited by descendants.
hiddenBoolean or reactive functionSets or reactively derives hidden state for the complete subtree.
disabledBoolean, reason string, or reactive functionSets or reactively derives disabled state for the complete subtree.
readonlyBoolean or reactive functionSets or reactively derives readonly state for the complete subtree.
injectorAngular InjectorExplicitly owns injector-dependent work such as asynchronous validation watchers.
inheritInjectorBoolean; defaults to trueAllows an injector-less group to use the nearest ancestor injector.
adoptBindingInjectorBoolean; defaults to trueAllows 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.

MemberDescription
Value and tree
myGroup()Returns the current committed object value. This is the preferred value-reading form.
myGroup.childReturns a named child node with its precise inferred type.
childrenStable readonly map of every current named child.
value()Current committed aggregate value. Equivalent to calling the 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.
$apiGuaranteed 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.

important

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.

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

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

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

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

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

Value change callback

onValueChange?(value: TValue, node: 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.