array()
For the exported ArrayNode model type and its generic counterpart, see the
Node types reference.
array() creates a dynamic collection of independently cloned nodes. It is not required merely
because a value is an array. When one control owns the complete arrayβfor example, a multi-selectβ
use a normal array-valued field() instead. Choose array() when items need independent nodes,
bindings, validation state, or structural operations.
Use FormNodeValue<typeof myArray> to extract an array node's value type.
array() can be safely created and used outside an Angular injection context. Value and structural
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 array tree can be garbage-collected.
Use array() when each item needs its own node, state, validation, binding, or structural
operations. When one control owns the complete array value, use field([]) instead. See
Choosing a primitive for the complete comparison.
import { array, field, form } from '@ngblocks/form-nodes';
const myForm = form({
people: array({
name: field(''),
age: field(18),
}, {
initialValue: [{ name: 'Mark', age: 50 }],
trackBy: 'name',
}),
});
Each item is its own form node. A primitive collection uses a field template:
const myForm = form({
tags: array(field(''), {
initialValue: ['angular', 'signals'],
}),
});
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 |
|---|---|---|
| Validate a field using a sibling in the same row | ctx.parent<TParent>() | Sibling validation |
| Prepare an item value without adding a row | templateValue() | Template values |
| Choose a template and initial items | array(template, ...) | Signatures and options |
| Read values, nodes, or array position | myArray(), items(), myArray[index] | Properties and methods |
| Search or iterate live item nodes | at(), forEach(), map(), find() | Collection methods |
| Add, remove, move, swap, or clear items | push(), removeAt(), move(), swap() | Structural methods |
| Replace, derive, patch, or reset values | set(), update(), patch(), reset(), resetToInitial() | Value update methods |
| Preserve identity across server updates | trackBy | Reconciliation |
| Inspect aggregate state | Validation, interaction, and availability signals | Validation, interaction, and availability |
| Commit, focus, or inspect submission state | flush(), focus(), submitting() | Control and submission |
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β
array(templateOrFactory);
array(templateOrFactory, initialValue);
array(templateOrFactory, initialValue, options?);
array(templateOrFactory, initialValue, validators, options?);
array(templateOrFactory, options);
array(templateOrFactory, validators, options?);
The positional initialValue argument accepts an item-value array, a non-negative safe integer
count, null, or undefined. In options, prefer initialLength for counts and initialValue
for data. Numeric options.initialValue remains supported for compatibility.
Nullish values normalize to []; an array node itself is never nullable.
const myForm = form({
attendees: array({
name: field(''),
confirmed: field(false),
}, {
initialLength: 3,
}),
});
An explicit factory is available when construction must be deferred:
const myForm = form({
rows: array(() => form({
label: field(''),
})),
});
The factory must return a fresh node each time.
β field() shorthands in object templatesβ
Inside an object template, field-value shorthands use the same normalization and TypeScript
inference as form() and group(). The object itself becomes a group(), while its concise leaf
values become independently cloned field() nodes:
import { array } from '@ngblocks/form-nodes';
const people = array({
name: '',
age: 0,
}, {
initialValue: [
{ name: 'Marco', age: 36 },
{ name: 'Lia', age: 32 },
],
});
people[0]?.name(); // 'Marco'
people[0]?.age(); // 36
people[0]?.nodeType() === 'group'; // true
people[0]?.name.nodeType() === 'field'; // true
if (people[0]?.name() !== 'Marco' || people[1]?.age() !== 32) {
throw new Error('Array object-template shorthands should preserve item values.');
}
if (people[0]?.nodeType() !== 'group' || people[0]?.name.nodeType() !== 'field') {
throw new Error('Array object-template shorthands should normalize to groups containing fields.');
}
The template above is equivalent to array({ name: field(''), age: field(0) }, ...). Every item
receives fresh field and group nodes; only the declared initial values are shared. This also works
for object templates returned by a factory.
The declaration shorthand matrix
compares these template declarations with their explicit equivalents and explains when a nested
array() is required.
An array inside the object template becomes one array-valued FieldNode; its length and contents do
not affect that decision. Use an explicit nested array(...) when its items need independent
nodes. Use field(objectValue) when a plain object is an atomic application value rather than
nested group structure.
Validate a field using a sibling in the same rowβ
Put the validator on the field in the object template. Inside that validator, ctx.parent()
refers to the current row, so you can read another field in that row. Supply the row's node
type to expose its child names in IntelliSense.
For example, each package has a deliveryMethod and a pickupLocation. The pickup location is
required only when that package's delivery method is 'pickup':
import { Component } from '@angular/core';
import { array, field, form, validator } from '@ngblocks/form-nodes';
@Component({ selector: 'app-delivery-editor', template: '' })
export class DeliveryEditor {
form = form({
packages: array({
deliveryMethod: field<'home' | 'pickup'>('home'),
pickupLocation: field('', (ctx) => {
const row = ctx.parent<(typeof this.form.packages)[number]>();
return row?.deliveryMethod() === 'pickup' && !ctx.value()
? { kind: 'pickupLocation', message: 'Choose a pickup location.' }
: null;
}),
}, {
initialValue: 2,
}),
});
}
type DeliveryForm = DeliveryEditor['form'];
export const pickupLocationRequired = validator<string | null>((ctx) => {
const row = ctx.parent<DeliveryForm['packages'][number]>();
return row?.deliveryMethod() === 'pickup' && !ctx.value()
? { kind: 'pickupLocation', message: 'Choose a pickup location.' }
: null;
});
Here, ctx.value() reads the pickup location being validated, while row?.deliveryMethod()
reads its sibling. Keep the sibling read inside the validator so changes to the delivery method
revalidate the pickup location. Every cloned row, including rows added later, resolves its own
parent; the rule does not read the first package or another row.
Both forms of the generic are supported:
ctx.parent<(typeof this.form.packages)[number]>();
ctx.parent<DeliveryForm['packages'][number]>();
Use the first inside the component and the second when an existing form type is available.
typeof this.form.packages[0] also works as the generic, but [number] communicates
that the type describes any row. This is a type reference, not a runtime index lookup.
Null and undefined are removed from the generic automatically; you do not need NonNullable.
The result still includes null when the field has no parent, hence the optional chaining.
The generic declares the structure you expect; it does not verify child names or placement at runtime. The parent is always the immediate structural parent. If the validated field is inside a nested group, that group is its parent. For a primitive field template, the parent is the array.
Validator context nodes expose a recursive read-only view, even with an explicit parent generic.
Read sibling values, rather than validation results such as valid(), invalid(), or
errors(), which are omitted to avoid circular validation dependencies. Mutations are also
unavailable through the context. See validator contexts.
Infer siblings with configureβ
To avoid an explicit parent contract, use a group() template with configure. Its typed
children belong to that row, and each new clone runs its own callback:
import { array, field, form, group, type ArrayItemNode, type FieldNode, type GroupNode } from '@ngblocks/form-nodes';
const deliveryForm = form({
packages: array(group({
deliveryMethod: field<'home' | 'pickup'>('home'),
pickupLocation: field(''),
}, {
configure: ({ children }) => {
children.pickupLocation.setValidators(() => {
return children.deliveryMethod() === 'pickup' && !children.pickupLocation()
? { kind: 'pickupLocation', message: 'Choose a pickup location.' }
: null;
});
},
}), {
initialValue: 2,
}),
});
type PackageNode = ArrayItemNode<typeof deliveryForm.packages>;
const first: PackageNode = deliveryForm.packages.at(0)!;
const second = deliveryForm.packages.at(1)!;
first.deliveryMethod.set('pickup');
first.pickupLocation.hasError('pickupLocation'); // true
second.pickupLocation.hasError('pickupLocation'); // false
if (!first.pickupLocation.hasError('pickupLocation') || second.pickupLocation.hasError('pickupLocation')) {
throw new Error('Sibling validation must stay within its own row.');
}
first.pickupLocation.set('Central station');
if (first.pickupLocation.invalid()) throw new Error('Choosing a pickup location must resolve the error.');
const added = deliveryForm.packages.push({ deliveryMethod: 'pickup', pickupLocation: '' });
if (!added.pickupLocation.hasError('pickupLocation')) throw new Error('New rows must also be configured.');
// A reusable field may instead declare the parent structure it requires.
type PackageParent = GroupNode<{ deliveryMethod: FieldNode<'home' | 'pickup' | null> }>;
const pickupLocation = field('', (ctx) => {
const row = ctx.parent<PackageParent>();
return row?.deliveryMethod() === 'pickup' && !ctx.value()
? { kind: 'pickupLocation', message: 'Choose a pickup location.' }
: null;
});
if (pickupLocation.invalid()) throw new Error('A detached field must tolerate a null parent.');
const standalonePackage = group({ deliveryMethod: field<'home' | 'pickup'>('pickup'), pickupLocation });
if (!standalonePackage.pickupLocation.hasError('pickupLocation')) {
throw new Error('The declared parent must be observed after attachment.');
}
standalonePackage.deliveryMethod.set('home');
if (standalonePackage.pickupLocation.invalid()) throw new Error('Changing the sibling must clear the error.');
The assertions demonstrate independent row validation, newly added rows, and a reusable field
with a parent contract. Put configure on the template group for row rules;
array(..., { configure }) configures the collection itself. See
configuring nodes and sibling rules for initialization and lifecycle details.
βοΈ Optionsβ
Arrays accept most of the options available to form(), together with array-specific
initialization and reconciliation options. They do not accept onSubmit: an array can report
the submission state inherited from an ancestor form, but cannot initiate submission itself.
Items created later from either a template or factory inherit the array's nearest injector by
default. Set inheritInjector: false on an item template or factory result to create a lifecycle
boundary for that item subtree.
Like every node, an array also adopts a directly bound [formNode] host injector by default. Use
adoptBindingInjector: false when rendering the array must not change its lifecycle owner.
| Option | Accepted value | Purpose |
|---|---|---|
configure | (api) => void | Configure this instance once with its typed, collision-safe API. |
equal | 'shallow', 'deep', or (previous, next) => boolean | Retains equivalent exposed array values; defaults to Object.is. |
initialLength | Non-negative safe integer | Creates independent items from template or factory defaults. Applies only during initialization. |
initialValue | Item-value array, non-negative integer, null, or undefined | Creates items from supplied values or creates a requested number of items from the template defaults. Nullish values produce an empty array. |
validators | Validator, validator array, null, or undefined | Validates the complete array value. Put validators on the item template instead when every item needs independent validation. |
validatorMessages | Message catalog or reactive catalog function | Overrides built-in validator messages for the array subtree. Validator-local messages still take precedence. |
trackBy | Item property name or (value, index) => key | Preserves logical item identity and state when set(), update(), or reset(value) reconciles the collection. |
debounce | Milliseconds, 'blur', or cancelable asynchronous function | Provides the default control-value debounce inherited by current and future items. |
hidden | Boolean or reactive function | Sets or reactively derives hidden state for the complete array subtree. |
disabled | Boolean, reason string, or reactive function | Sets or reactively derives disabled state for the complete array subtree. A string is exposed through disabledReasons(). |
readonly | Boolean or reactive function | Sets or reactively derives readonly state for the complete array subtree. |
injector | Angular Injector | Explicitly owns injector-dependent work such as asynchronous validation watchers. Ordinary synchronous use does not require one. |
inheritInjector | Boolean; defaults to true | Allows the array to use the nearest ancestor injector when it has no injector of its own. Set it to false to create an inheritance boundary. |
adoptBindingInjector | Boolean; defaults to true | Allows the array to adopt the injector of a directly bound [formNode] host while that binding exists. |
You do not need to configure trackBy for ordinary arrays. Without it, complete value updates
reuse existing item nodes by index. Add trackBy only when items have a stable domain identity and
may be reordered or replaced with new objects while their node identity and state should be
preserved.
Choose a positional value/count, options.initialValue, or options.initialLength. TypeScript
rejects conflicting sources, and untyped calls throw TypeError before creating items. In multiline
examples, prefer initialLength for counts and initialValue for data.
βοΈ Option referenceβ
configureβ
Signature: configure?: (api: TArray['$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.
This callback configures the array itself through items(). For per-row sibling rules, use
configureEach or put configure on a group() or form() template.
See configuring nodes and sibling rules for an executable example, parent contracts, initialization order, and lifecycle details.
configureEachβ
Default: undefined; no per-item configuration.
Receives each new item's inferred, collision-safe callable $api, just as configure receives
this array's API. Works with object templates, node templates, and factories. For object items,
use api.children to access typed siblings; fields expose set() and arrays expose items().
Runs synchronously and untracked after the item's own configuration and supplied initial data,
before attachment and capture of its reset-to-initial baseline. Initialization and configuration
writes do not notify value-change listeners. No injector is required or implicitly created.
The original template and templateValue() drafts do not run this callback. Existing items do
not rerun it on edits, moves, or resets; newly created items do. Returned values are ignored.
See the complete example and lifecycle contract.
Each option below includes its signature, default behavior, scope, and a complete example.
β Values and validationβ
β equalβ
Signature: equal?: 'shallow' | 'deep' | ((previous: TValue, next: TValue) => boolean)
Applies equality to the complete exposed array. The comparator receives the inferred array value,
including nullable item properties. The node call, equivalent value() signal, array validators,
public parents, and update() callbacks all observe the exposed value. Ancestor form submission
also receives that public representation.
import { array, field, form } from '@ngblocks/form-nodes';
let submitted: unknown;
const profile = form({
contacts: array({
name: field.strict<string>(''),
}, {
initialValue: [{ name: 'Marco' }],
equal: (previous, next) => {
return previous.length === next.length
&& previous.every((contact, index) => contact.name.toLowerCase() === next[index]!.name.toLowerCase());
},
}),
}, {
onSubmit: value => { submitted = value; },
});
const initial = profile();
profile.contacts[0]!.name.value.control.set('MARCO');
profile.contacts[0]!.name(); // 'MARCO': the item accepts the new value
profile.contacts(); // [{ name: 'Marco' }]: array equality retains the exposed snapshot
profile(); // { contacts: [{ name: 'Marco' }] }: parents compose exposed child values
if (profile.contacts[0]!.name() !== 'MARCO' || profile() !== initial || !profile.dirty()) {
throw new Error('Array equality should preserve the public value while item values and interaction change.');
}
await profile.submit();
if (submitted !== initial) {
throw new Error('Submission should receive the exposed form value.');
}
profile.reset();
profile.contacts[0]!.name(); // 'MARCO': reset preserves the current committed value
if (profile.contacts[0]!.name() !== 'MARCO' || profile() !== initial || profile.dirty() || profile.touched()) {
throw new Error('Reset should preserve committed items and clear interaction independently of equality.');
}
profile.contacts.update(contacts => contacts.map(contact => ({ name: `${contact.name}!` })));
profile.contacts[0]!.name(); // 'Marco!': update receives the exposed array value
if (profile.contacts[0]!.name() !== 'Marco!') {
throw new Error('Array update should receive the exposed value.');
}
'shallow'compares array entries withObject.is; object entries need matching references.'deep'compares nested values using the same recursive semantics as field equality.- A custom comparator must treat values as interchangeable for consumers and validation.
Equality is captured at construction, is not inherited by items, and survives configured factories and template cloning. It runs untracked during lazy exposed computation. The first evaluation does not compare, intermediate writes may coalesce, and comparator errors affect exposed reads after item writes or structural operations have already completed.
equal does not control node identity or structure. items(), indexed access, length(), paths,
and trackBy reconciliation always follow the current collection. If a comparator ignores order
or length, the retained exposed array may differ from the current item order or count. Render
dynamic rows from items() and track their nodes as shown in the dynamic arrays guide.
Controls, reset, and debounce use current committed values. Reordering equal-valued nodes still invalidates obsolete pending control input. See Aggregate value equality for the shared contract.
β initialLengthβ
Signature: initialLength?: number
Default: undefined; use another initial source or create an empty array.
Creates the requested number of independent nodes from the template or factory defaults.
Each new item runs configureEach. Accepts non-negative safe integers, including zero;
invalid counts throw RangeError. Cannot be combined with initialValue or a positional
initial value/count. This does not constrain future edits, pad incoming data, or truncate it.
resetToInitial() restores the captured initial collection using normal reconciliation.
import { array, field, form } from '@ngblocks/form-nodes';
const myForm = form({
people: array({
name: field(''),
}, {
initialLength: 3,
}),
});
myForm.people.length(); // 3
See the executable initialization example.
β initialValueβ
Signature: initialValue?: readonly ItemValue[] | number | null
Creates the initial item nodes. An array supplies each item's value; a non-negative integer creates
that many items from the template defaults. null, undefined, and omission produce an empty
array.
const users = array({
username: field(''),
active: field(false),
}, {
initialValue: [
{ username: 'ada', active: true },
{ username: 'grace', active: false },
],
});
users.length(); // 2
Numeric initialValue remains supported for compatibility. Prefer initialLength when creating
a count of independent items from template defaults. Do not combine the two options:
const users = array({
username: field(''),
active: field(false),
}, {
initialValue: 3,
});
users.length(); // 3
users();
// [
// { username: '', active: false },
// { username: '', active: false },
// { username: '', active: false },
// ]
β validatorsβ
Signature: validators?: ValidatorSource<ArrayValue, ArrayNode<TItem>>
Assigns one validator or an array of validators to the complete collection value. Item-template validators still validate each item independently.
const usernames = array(field(''), {
initialValue: ['ada'],
validators: [minLength(2), uniqueItems],
});
usernames.invalid(); // true
β validatorMessagesβ
Signature: validatorMessages?: ValidatorMessages | (() => ValidatorMessages | undefined)
Overrides built-in validator messages for this array and its descendants. It can be a static catalog or a reactive function; a message configured directly on a validator has higher priority.
const users = array({
username: field('', [required]),
}, {
initialValue: [{ username: '' }],
validatorMessages: {
required: 'Enter a username.',
},
});
users.allErrors()[0]?.message; // 'Enter a username.'
β trackByβ
Signature: trackBy?: keyof ItemValue | ((value: ItemValue, index: number) => unknown)
Selects stable item identity during complete reconciliation. Matching keys preserve and, when needed, move existing nodes with their interaction and validation state. Without this option, nodes are reused by index.
const users = array({
id: field(''),
username: field(''),
}, {
initialValue: [
{ id: 'user-1', username: 'ada' },
{ id: 'user-2', username: 'grace' },
],
trackBy: 'id',
});
β debounceβ
Signature: debounce?: number | 'blur' | ((abortSignal: AbortSignal) => void | PromiseLike<void>)
Provides the default control-value debounce inherited by current and future items. Omit it to commit control-originated values immediately.
const usernames = array(field(''), {
initialValue: ['ada'],
debounce: 300,
});
β Availabilityβ
β hiddenβ
Signature: hidden?: boolean | (() => boolean)
Sets the initial hidden state or derives it reactively for the complete array subtree. It defaults
to false.
const usernames = array(field(''), {
hidden: () => !showUsernames(),
});
usernames.hidden(); // follows showUsernames()
β disabledβ
Signature: disabled?: boolean | string | (() => boolean | string)
Sets or reactively derives disabled state for the complete subtree. A string both disables the
array and becomes a message in disabledReasons(). It defaults to false.
const usernames = array(field(''), {
disabled: 'Profile is locked',
});
usernames.disabled(); // true
usernames.disabledReasons()[0]?.message; // 'Profile is locked'
β readonlyβ
Signature: readonly?: boolean | (() => boolean)
Sets the initial readonly state or derives it reactively for the complete array subtree. It
defaults to false.
const usernames = array(field(''), {
readonly: () => profileArchived(),
});
usernames.readonly(); // follows profileArchived()
β Injector ownershipβ
β injectorβ
Signature: injector?: Injector
Provides an explicit Angular injector for injector-dependent work. Its DestroyRef owns the
array's asynchronous-validation watcher. Synchronous array behavior does not require an injector.
const injector = inject(Injector);
const usernames = array(field(''), {
injector,
});
β inheritInjectorβ
Signature: inheritInjector?: boolean
Controls whether an otherwise injector-less array may use the nearest ancestor injector. It
defaults to true; false creates an inheritance boundary without discarding an explicit or
currently captured injector.
const profile = form({
usernames: array(field(''), {
inheritInjector: false,
}),
});
β adoptBindingInjectorβ
Signature: adoptBindingInjector?: boolean
Controls whether an otherwise injector-less array may temporarily adopt the injector of a directly
bound [formNode] host. It defaults to true; set it to false when rendering must not change the
array's lifecycle owner.
const usernames = array(field(''), {
adoptBindingInjector: false,
});
π Properties and methodsβ
An array node is a callable value reader with reactive signal properties, collection operations, and the shared node state API. Signal properties must be called to read their current value.
| Member | Description |
|---|---|
| Value and tree | |
myArray() | Returns the exposed array value, applying equal. This is the preferred value-reading form. |
myArray[index] | Returns the live item node at an index, or undefined. |
value() | Exposed value. Equivalent to calling the array node directly. |
value.committed() | Latest committed data before configured equality checks. |
value.committed.set(value) | Complete immediate write, equivalent to set(). |
value.control() | Immediate value from a control bound directly to the array; it can differ during debounce. |
value.control.set(value) | Receives control input with debounce and dirty tracking. |
items() | Readonly array of current live item nodes. Its reference changes with the structure. |
length() | Current number of item nodes. |
nodeType() | Returns the literal 'array'. |
form() | Nearest explicit form workflow, or null when none owns the array. |
root() | Complete structural root; a root array 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 | Callable, collision-safe API for generic infrastructure. |
| Item access and collection | |
templateValue() | Returns a typed, independent item value without adding a row. |
at(index) | Returns the live item node at an index, counting negative indexes from the end, or undefined. |
forEach(callback) | Invokes a callback once for every current item node. |
map(callback) | Maps item nodes into a new plain array. |
filter(predicate) | Returns item nodes accepted by the predicate. |
find(predicate) | Returns the first matching item node, or undefined. |
findIndex(predicate) | Returns the first matching node index, or -1. |
some(predicate) | Whether at least one item node matches. |
every(predicate) | Whether every item node matches. |
includes(item, fromIndex?) | Whether an exact node instance is present. |
indexOf(item, fromIndex?) | Position of an exact node instance, or -1. |
[Symbol.iterator]() | Iterates current item nodes with for...of or spread syntax. |
| Structure | |
push(value?) | Appends and returns a new item node, using template defaults when no value is supplied. |
insert(index, value?) | Inserts and returns a new item node while shifting later items. |
removeAt(index) | Removes, detaches, and returns an item node, or undefined. |
moveUp(index) | Moves an item one position toward the start. |
moveDown(index) | Moves an item one position toward the end. |
move(fromIndex, toIndex) | Moves an existing node and shifts intervening items. |
swap(firstIndex, secondIndex) | Exchanges two existing item nodes. |
clear() | Removes and detaches every current item. |
| Value updates | |
set(value) | Reconciles the complete collection. A nullish value clears it. |
update(updater) | Derives and reconciles a complete value from the current plain value. |
patch(values) | Reconciles the complete collection, exactly like set(). |
reset(value?) | Optionally reconciles 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 array. |
setValidators(source) | Replaces the array validator source and revalidates. |
errors() | Errors owned directly by this array, excluding descendants. |
allErrors() | Errors from this array and every current descendant. |
getError(kind) | First array-owned error with a kind, or undefined. |
valid() | Whether the complete array subtree is valid. |
invalid() | Whether the array or a current descendant is invalid. |
required() | Whether active metadata marks this array itself as required. |
pending() | Whether asynchronous validation is active in the subtree. |
validationStatus() | Aggregated 'valid', 'invalid', or 'unknown' phase. |
| Interaction | |
touched() | Whether the array or a contributing descendant is touched. |
untouched() | Logical inverse of touched(). |
markAsTouched(options?) | Marks the array and, by default, its item subtrees touched. |
markAsUntouched() | Clears the array's own touched state. |
dirty() | Whether the array or a contributing descendant is dirty. |
pristine() | Logical inverse of dirty(). |
markAsDirty() | Marks the array's own state dirty. |
markAsPristine() | Clears the array's own dirty state. |
| Availability | |
disabled() | Whether the array 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 array is readonly locally or through an ancestor. |
writable() | Logical inverse of readonly(). |
markAsReadonly() | Marks the array subtree readonly. |
markAsWritable() | Clears the imperative readonly state. |
hidden() | Whether the array is hidden locally or through an ancestor. |
visible() | Logical inverse of hidden(). |
hide() | Marks the array subtree hidden. |
show() | Clears the imperative hidden state. |
| Control and submission | |
debouncing() | Whether a descendant has a pending debounced control value. |
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. |
myForm.people(); // [{ name: 'Mark', age: 50 }]
myForm.people.length(); // 1
myForm.people.at(0); // first live person node
myForm.people[0]?.name(); // 'Mark'
myForm.people.path(); // ['people']
Use items() when a reactive readonly node list is needed. Use spread syntax or Array.from() to
create a mutable copy; mutating that copy does not change the form array.
π Item access and collection methodsβ
These methods operate on item nodes, not on their plain values. Their callbacks receive
(item, index, arrayNode). See the consolidated properties and methods
table for their signatures and summaries.
for (const person of myForm.people) {
console.log(person.name());
}
const adultNodes = myForm.people.filter(person => (person.age() ?? 0) >= 18);
const names = myForm.people.map(person => person.name());
In Angular templates, track the node to preserve DOM and control bindings while reordering:
@for (person of myForm.people; track person) {
<input [formNode]="person.name" />
}
π³ Structural methodsβ
Reordering retains the exact node instances, including their interaction state, validation state,
and pending work. Paths are updated after the move. Invalid insertion, movement, and swap indexes
throw RangeError.
Structural operations are programmatic and do not mark the array dirty automatically.
π Value update methodsβ
π§ͺ Structural examplesβ
myForm.people.push();
myForm.people.push({ name: 'Lia', age: 28 });
myForm.people.insert(1, { name: 'Noa', age: 34 });
myForm.people.removeAt(0);
myForm.people.moveUp(2);
myForm.people.moveDown(0);
myForm.people.move(3, 1);
myForm.people.swap(0, 2);
myForm.people.clear();
π Complete and partial value updatesβ
set(), patch(), and update() reconcile complete collections. Each supplied item must
provide its complete set value. The incoming length and order replace the previous structure,
while matching nodes retain their identities and interaction state.
import { array, field, form } from '@ngblocks/form-nodes';
const profile = form({
username: field('Ada'),
details: {
note: field('Keep this note'),
cities: array({
city: field(''),
country: field(''),
}, {
initialValue: [{ city: 'Madrid', country: 'Spain' }],
}),
},
});
profile.patch({ details: { cities: [
{ city: 'Rabat', country: 'Morocco' },
{ city: 'Valencia', country: 'Spain' },
] } });
profile.username(); // 'Ada'
profile.details.note(); // 'Keep this note'
profile.details.cities.length(); // 2
if (profile.username() !== 'Ada' || profile.details.note() !== 'Keep this note'
|| profile.details.cities.length() !== 2 || profile.details.cities[0]?.country() !== 'Morocco') {
throw new Error('A parent patch must preserve omitted branches and reconcile complete array values.');
}
profile.details.cities.patch([{ city: 'Paris', country: 'France' }]);
profile.details.cities(); // [{ city: 'Paris', country: 'France' }]
if (profile.details.cities.length() !== 1 || profile.details.cities[0]?.country() !== 'France') {
throw new Error('An array patch must remove trailing rows and assign complete item values.');
}
profile.details.cities.at(0)?.patch({ city: 'Lyon' });
profile.details.cities(); // [{ city: 'Lyon', country: 'France' }]
if (profile.details.cities[0]?.city() !== 'Lyon' || profile.details.cities[0]?.country() !== 'France') {
throw new Error('A row patch must preserve omitted row properties.');
}
profile.patch({ details: { cities: [] } });
profile.details.cities(); // []
if (profile.details.cities.length() !== 0) throw new Error('An empty array patch must clear the collection.');
// Runtime fallback for data that bypasses the complete-item TypeScript contract.
const people = form({
users: array({
username: field(''),
age: field<number | null>(null),
}, {
initialValue: [{ username: 'previous', age: 28 }],
}),
});
const externalData = JSON.parse('[{"username":"tobi"},{"username":"andrew"}]');
people.patch({ users: externalData });
people.users(); // [{ username: 'tobi', age: null }, { username: 'andrew', age: null }]
if (people.users.length() !== 2 || people.users().some(user => user.age !== null)) {
throw new Error('Omitted properties must use declaration defaults for both reused and new rows.');
}
Call an individual object row's patch() to update selected properties without changing the
collection. Sparse arrays are not positional patches; supply a complete dense collection.
Passing null or undefined to set() or patch(), returning it from update(), or supplying
it to reset(value) clears the array.
π Reconciliation and trackByβ
Without trackBy, complete updates reuse nodes by index. Use a stable domain key when server data
can be reordered or replaced with new objects:
const myForm = form({
people: array({
id: field(''),
name: field(''),
}, {
initialValue: [
{ id: 'ada', name: 'Ada' },
{ id: 'grace', name: 'Grace' },
],
trackBy: 'id',
}),
});
A callback supports computed or composite identities:
const people = array({
organizationId: field(''),
id: field(''),
name: field(''),
}, {
initialValue: initialPeople,
trackBy: person => `${person.organizationId}:${person.id}`,
});
Matching keys retain nodes and their state while paths update. New keys create nodes, absent keys detach nodes, and duplicate keys throw before mutation.
β Validation properties and methodsβ
An array's validators receive its complete plain value. Validation and pending state aggregate the array's own state with that of its current descendants.
Start with one collection validator and add an array only when multiple rules are needed:
const myForm = form({
tags: array(field(''), {
validators: minLength(1),
}),
roles: array(field(''), {
validators: [minLength(1), uniqueItems],
}),
});
These rules validate each complete array. Put validators on field('') instead when the rule must
run independently for every item. The consolidated properties and methods
table summarizes every validation member.
myForm.people.errors(); // errors belonging to `people`
myForm.people.allErrors(); // errors from `people` and its item nodes
myForm.people.getError('uniqueItems');
π Interaction properties and methodsβ
See the consolidated properties and methods table for every interaction signal and operation.
Programmatic value and structural operations do not mark nodes dirty. reset() recursively clears
interaction state after restoring or replacing the value.
ποΈ Availability properties and methodsβ
See the consolidated properties and methods table for every availability signal and operation.
π Control and submission properties and methodsβ
See the consolidated properties and methods table for the control and submission members.
π Property referenceβ
Each entry includes its consumer-facing signature, what it represents or returns, and a complete
example. In the signatures below, ItemNode means the node cloned from the array template,
ItemValue means that node's plain value, ItemSet means its complete set value, and ArrayValue means ItemValue[]. ParentNode and RootNode represent the
precise parent and root types inferred from where the array is declared.
min() is not included because it is a field constraint signal, not an array property; see the
field() reference.
β Value and tree propertiesβ
β Callable valueβ
Signature: (): ArrayValue
Calls the array node as a signal and returns its exposed plain value, applying configured equality. This is the recommended way to read an array value.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
usernames(); // ['ada', 'grace']
β Indexed accessβ
Signature: readonly [index: number]: ItemNode | undefined
Returns the live item node at an index, or undefined when that index does not exist.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
const username = usernames[1];
username?.(); // 'grace'
usernames[20]; // undefined
β value()β
Signature: value: NodeValueSignal<ArrayValue, ArraySet | null | undefined>
Contains the exposed plain value, including any previous array retained by equality.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
usernames.value(); // ['ada', 'grace']
Prefer the equivalent callable form, usernames(), for ordinary value reads.
β value.committed()β
Signature: value.committed: Signal<ArrayValue> & { set(value: ArraySet | null | undefined): 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: ArraySet | null | undefined): 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<ArrayValue>
Contains the immediate value reported by a control bound directly to the array.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
usernames.value.control(); // ['ada', 'grace']
This can temporarily differ from usernames() when a control is bound directly to the array and
its value is awaiting a debounced commit.
β value.control.set()β
Signature: value.control.set(value: ArraySet | null | undefined): 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.
β items()β
Signature: items: Signal<readonly ItemNode[]>
Contains the current live item nodes in index order. The returned array is readonly and is replaced whenever the structure changes.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
const usernameNodes = usernames.items();
usernameNodes[0]?.(); // 'ada'
β length()β
Signature: length: Signal<number>
Contains the current number of live item nodes and is equivalent to items().length.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
usernames.length(); // 2
β nodeType()β
Signature: nodeType(): 'array'
Returns the stable primitive discriminant for this node.
const usernames = array(field(''));
usernames.nodeType(); // 'array'
β form()β
Signature: form: Signal<FormNode | null>
Returns the nearest explicit form() containing the array. A standalone or detached array returns
null because it does not own a form workflow.
const profile = form({
usernames: array(field(''), {
initialValue: ['ada', 'grace'],
}),
});
profile.usernames.form() === profile; // true
β root()β
Signature: root: Signal<RootNode>
Returns the complete structural root containing the array. A standalone or detached array returns itself.
const usernames = array(field(''));
usernames.root() === usernames; // true
β parent()β
Signature: parent: Signal<ParentNode | null>
Returns the direct parent node, or null when the array is a root or has been detached.
const profile = form({
usernames: array(field(''), {
initialValue: ['ada', 'grace'],
}),
});
profile.usernames.parent() === profile; // true
β path()β
Signature: path: Signal<readonly string[]>
Returns the property and index segments from the root to the array.
const profile = form({
usernames: array(field(''), {
initialValue: ['ada', 'grace'],
}),
});
profile.usernames.path(); // ['usernames']
β keyInParent()β
Signature: keyInParent: Signal<string | number | null>
Returns the property name or array index under which the node is stored, or null at the root.
const profile = form({
usernames: array(field(''), {
initialValue: ['ada', 'grace'],
}),
});
profile.usernames.keyInParent(); // 'usernames'
profile.usernames[0]?.keyInParent(); // 0
β $apiβ
Signature: $api: CallableNodeApi<CallableNodeApi<ArrayApi<ItemNode>>>
Exposes the complete callable API through a name that cannot collide with a user-defined child.
const profile = form({
api: field('public-profile-api'),
usernames: array(field(''), {
initialValue: ['ada', 'grace'],
}),
});
profile.api(); // 'public-profile-api'
profile.$api.valid(); // true
profile.usernames.$api.length(); // 2
$api is the guaranteed collision-safe API path.
β Validation propertiesβ
β validators()β
Signature: validators: Signal<Validators<ArrayValue>> & { (options: { resolve?: boolean }): Validators<ArrayValue> }
Contains the normalized validators owned directly by the array, in declaration order.
const usernames = array(field(''), {
initialValue: ['ada'],
validators: minLength(2),
});
usernames.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 array and excludes descendant errors.
const usernames = array(field(''), {
initialValue: ['ada'],
validators: minLength(2),
});
usernames.errors()[0]?.kind; // 'minLength'
Only errors owned directly by the array are returned.
β allErrors()β
Signature: allErrors: Signal<readonly ValidationError[]>
Shortcut for errors({ descendants: true }), returning the same cached array.
Contains errors from the array and every current descendant. Each error identifies its
targetNode.
const users = array({
username: field('', [required]),
}, {
initialValue: [{ username: '' }],
});
users.allErrors()[0]?.kind; // 'required'
users.errors(); // []
β valid()β
Signature: valid: Signal<boolean>
Returns whether the array and every current item subtree have completed validation without errors.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
validators: minLength(2),
});
usernames.valid(); // true
β invalid()β
Signature: invalid: Signal<boolean>
Returns whether the array or any current item subtree contributes a validation error.
const usernames = array(field(''), {
initialValue: ['ada'],
validators: minLength(2),
});
usernames.invalid(); // true
β required()β
Signature: required: Signal<boolean>
Returns whether active validation metadata marks the array itself as required.
const usernames = array(field(''), {
initialValue: [],
validators: required,
});
usernames.required(); // true
β pending()β
Signature: pending: Signal<boolean>
Returns whether asynchronous validation is currently active on the array or an item subtree.
const usernames = array(field(''), {
validators: asyncValidator(async () => {
await checkUsernames();
return null;
}),
});
usernames.pending(); // true while checkUsernames() is running
β validationStatus()β
Signature: validationStatus: Signal<'valid' | 'invalid' | 'unknown'>
Returns the aggregate validation phase for the array and its current item subtrees.
const usernames = array(field(''), {
initialValue: ['ada'],
validators: minLength(2),
});
usernames.validationStatus(); // 'invalid'
The other possible values are 'valid' and 'unknown'. Unknown means asynchronous validation is
pending and no error currently makes the subtree invalid.
β Interaction propertiesβ
β touched()β
Signature: touched: Signal<boolean>
Returns whether the array or any contributing current item subtree has been marked touched.
const usernames = array(field(''), {
initialValue: ['ada'],
});
usernames.markAsTouched();
usernames.touched(); // true
β untouched()β
Signature: untouched: Signal<boolean>
Returns the logical inverse of touched().
const usernames = array(field(''), {
initialValue: ['ada'],
});
usernames.untouched(); // true
β dirty()β
Signature: dirty: Signal<boolean>
Returns whether the array or any contributing current item subtree reports user-modified state.
const usernames = array(field(''), {
initialValue: ['ada'],
});
usernames.markAsDirty();
usernames.dirty(); // true
β pristine()β
Signature: pristine: Signal<boolean>
Returns the logical inverse of dirty().
const usernames = array(field(''), {
initialValue: ['ada'],
});
usernames.pristine(); // true
β Availability propertiesβ
β disabled()β
Signature: disabled: Signal<boolean>
Returns whether the array is effectively disabled by its own state, configuration, or an ancestor.
const usernames = array(field(''), {
disabled: true,
});
usernames.disabled(); // true
β disabledReasons()β
Signature: disabledReasons: Signal<readonly DisabledReason[]>
Contains every active local and inherited cause of the disabled state, including its source node and optional message.
const usernames = array(field(''), {
disabled: 'Locked',
});
usernames.disabledReasons();
// [{ sourceNode: usernames, message: 'Locked' }]
β enabled()β
Signature: enabled: Signal<boolean>
Returns the logical inverse of disabled().
const usernames = array(field(''));
usernames.enabled(); // true
β readonly()β
Signature: readonly: Signal<boolean>
Returns whether the array is effectively readonly through its own state or an ancestor.
const usernames = array(field(''), {
readonly: true,
});
usernames.readonly(); // true
β writable()β
Signature: writable: Signal<boolean>
Returns the logical inverse of readonly() and indicates whether a directly bound control may
commit value changes.
const usernames = array(field(''));
usernames.writable(); // true
β hidden()β
Signature: hidden: Signal<boolean>
Returns whether the array is effectively hidden through its own state or an ancestor.
const usernames = array(field(''), {
hidden: true,
});
usernames.hidden(); // true
β visible()β
Signature: visible: Signal<boolean>
Returns the logical inverse of hidden().
const usernames = array(field(''));
usernames.visible(); // true
β Control and submission propertiesβ
β debouncing()β
Signature: debouncing: Signal<boolean>
Returns whether the array or a current item subtree has a control-originated value awaiting commit.
const usernames = array(field('', {
debounce: 300,
}), {
initialValue: ['ada'],
});
usernames.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. Arrays cannot initiate submission themselves.
const profile = form({
usernames: array(field(''), {
initialValue: ['ada'],
}),
}, {
onSubmit: async () => saveProfile(),
});
profile.usernames.submitting(); // true while saveProfile() is running
π Method referenceβ
Each entry includes its consumer-facing signature, its behavior and return value, and a complete
example. The examples alternate between primitive array(field()) items and form-object
array({ username: field() }) items so both node shapes are represented.
Prepare an item valueβ
templateValue() returns the plain value for one item, with its inferred child types. Use it to
prepare a draft before calling push(draft) or insert(index, draft). It also works on an empty array
and is available through $api.templateValue() for generic code.
import { array, field, form } from '@ngblocks/form-nodes';
const profile = form({
users: array({
username: field(''),
role: field('reader'),
}),
});
const draft = profile.users.templateValue();
draft.username = 'Ada';
if (profile.users.length() !== 0 || profile.users.templateValue().username !== '') {
throw new Error('Preparing a draft must leave the collection and template unchanged.');
}
// Add the draft only when the user confirms it.
profile.users.push(draft);
profile.users.at(0)?.username(); // 'Ada'
if (profile.users.at(0)?.username() !== 'Ada' || profile.users.at(0)?.role() !== 'reader') {
throw new Error('The added row must contain the draft and template defaults.');
}
For template declarations, the method reads captured declaration defaults. Existing rows, later
edits to the source template node, dynamically added source children, and the outer array's
initialValue do not change those defaults. Nested array values include their own declared initial
contents. Calling the method does not construct nodes, run validators or configure(), add rows,
mark the collection dirty/touched, or notify its value-change callback. It does not track signal reads.
Each call copies plain objects, arrays, Date, Map, and Set, including cycles and shared references
within a captured value. Files, class instances, and other opaque objects retain their references;
accessor descriptors are preserved, but their external state is not captured. This is the same
copy policy used by resetToInitial().
For factory declarations, each call executes the factory and initializes a fresh detached item.
The result is a copy of that item's committed value after configuration. Factory code, configuration,
and normal validation effects can run; signal reads are sampled without becoming dependencies of
the caller. The method does not attach the item to the array. Factory errors propagate, and factories
must return fresh definitions on every call, just as they must for push(). Returning a node that
already belongs to another parent is rejected.
β Read and iterate item nodesβ
β at()β
Signature: at(index: number): ItemNode | undefined
Returns the current live node at an index. Negative indexes count backward from the end:
-1 selects the last item, -2 the previous item, and -length() the first item.
An empty collection or an index outside either end returns undefined.
Index conversion follows Array.prototype.at(): fractional indexes truncate toward zero,
NaN selects index zero, and either infinity returns undefined.
import { array, field, form } from '@ngblocks/form-nodes';
const profile = form({
users: array({ username: field('') }, {
initialValue: [{ username: 'Ada' }, { username: 'Grace' }],
}),
});
profile.users.at(-1)?.username(); // 'Grace'
profile.users.at(-2)?.username(); // 'Ada'
profile.users.at(-3); // undefined
if (profile.users.at(-1) !== profile.users[1]
|| profile.users.at(-2) !== profile.users[0]
|| profile.users.at(-3) !== undefined) {
throw new Error('Negative indexes must select live nodes from the end of the collection.');
}
profile.users.at(-1)?.username.set('Lia');
profile().users[1]?.username; // 'Lia'
if (profile().users[1]?.username !== 'Lia') {
throw new Error('Editing the last node must update the parent form value.');
}
Inside a computed() or template, at(-1) tracks the current collection, so the selected
node follows additions, removals, and reordering. It returns the existing node instance;
its methods update the same item and propagate normally to its parent form.
β forEach()β
Signature: forEach(callback: (item: ItemNode, index: number, array: ArrayNode) => void): void
Runs a callback once for every current item node, in index order. The callback receives the item, its index, and the array node itself.
const users = array({
username: field(''),
active: field(false),
}, {
initialValue: [
{ username: 'ada', active: true },
{ username: 'grace', active: false },
],
});
const labels: string[] = [];
users.forEach((user, index, arrayNode) => {
labels.push(`${index + 1}/${arrayNode.length()}: ${user.username()}`);
});
// Expected output:
// ['1/2: ada', '2/2: grace']
β map()β
Signature: map<TResult>(callback: (item: ItemNode, index: number, array: ArrayNode) => TResult): TResult[]
Transforms the current item nodes into a new plain array without changing the form array.
const usernames = array(field(''), {
initialValue: ['ada', 'grace', 'linus'],
});
const uppercaseUsernames = usernames.map(username => username().toUpperCase());
// ['ADA', 'GRACE', 'LINUS']
β filter()β
Signature: filter(predicate: (item: ItemNode, index: number, array: ArrayNode) => unknown): ItemNode[]
Returns a new plain array containing the live item nodes accepted by the predicate. It does not remove items from the form array.
const users = array({
username: field(''),
active: field(false),
}, {
initialValue: [
{ username: 'ada', active: true },
{ username: 'grace', active: false },
{ username: 'linus', active: true },
],
});
const activeUsers = users.filter(user => user.active());
activeUsers.map(user => user.username()); // ['ada', 'linus']
β find()β
Signature: find(predicate: (item: ItemNode, index: number, array: ArrayNode) => unknown): ItemNode | undefined
Returns the first live item node accepted by the predicate, or undefined when none matches.
const users = array({
username: field(''),
active: field(false),
}, {
initialValue: [
{ username: 'ada', active: true },
{ username: 'grace', active: false },
],
});
const user = users.find(user => user.username() === 'grace');
user?.active(); // false
β findIndex()β
Signature: findIndex(predicate: (item: ItemNode, index: number, array: ArrayNode) => unknown): number
Returns the index of the first matching item node, or -1 when none matches.
const usernames = array(field(''), {
initialValue: ['ada', 'grace', 'linus'],
});
usernames.findIndex(username => username() === 'linus'); // 2
usernames.findIndex(username => username() === 'noa'); // -1
β some()β
Signature: some(predicate: (item: ItemNode, index: number, array: ArrayNode) => unknown): boolean
Returns true when at least one current item node satisfies the predicate.
const usernames = array(field(''), {
initialValue: ['ada', 'grace', 'linus'],
});
usernames.some(username => username().startsWith('g')); // true
β every()β
Signature: every(predicate: (item: ItemNode, index: number, array: ArrayNode) => unknown): boolean
Returns true when every current item node satisfies the predicate. It also returns true for an
empty array.
const usernames = array(field(''), {
initialValue: ['ada', 'grace', 'linus'],
});
usernames.every(username => username().length >= 3); // true
β includes()β
Signature: includes(item: AnyNode, fromIndex?: number): boolean
Checks whether the exact node instance is present. It compares node identity, not item values. An optional second argument selects the index at which the search begins.
const usernames = array(field(''), {
initialValue: ['ada', 'grace', 'linus'],
});
const username = usernames.at(0)!;
usernames.includes(username); // true
usernames.includes(username, 1); // false
β indexOf()β
Signature: indexOf(item: AnyNode, fromIndex?: number): number
Returns the position of an exact node instance, or -1 if it is absent. An optional second
argument selects the index at which the search begins.
const usernames = array(field(''), {
initialValue: ['ada', 'grace', 'linus'],
});
const username = usernames.at(1)!;
usernames.indexOf(username); // 1
usernames.indexOf(username, 2); // -1
β Symbol.iteratorβ
Signature: [Symbol.iterator](): IterableIterator<ItemNode>
Iteration yields the live item nodes. This supports for...of and spread syntax.
const usernames = array(field(''), {
initialValue: ['ada', 'grace', 'linus'],
});
for (const username of usernames) {
console.log(username());
}
const usernameNodes = [...usernames];
β Change the structureβ
Structural methods preserve retained node identity and do not mark the array dirty automatically.
β push()β
Signatures: push(): ItemNode Β· push(value: ItemValue): ItemNode
Creates a node at the end and returns it. Omit the value to use the item template defaults.
const users = array({
username: field(''),
active: field(false),
}, {
initialValue: [{ username: 'ada', active: true }],
});
const user = users.push({ username: 'grace', active: false });
user.username(); // 'grace'
const emptyUser = users.push();
emptyUser(); // { username: '', active: false }
β insert()β
Signatures: insert(index: number): ItemNode Β· insert(index: number, value: ItemValue): ItemNode
Creates and returns a node at the requested insertion index, shifting later items to the right.
The index may range from 0 through the current length; an invalid index throws RangeError.
const users = array({
username: field(''),
active: field(false),
}, {
initialValue: [
{ username: 'ada', active: true },
{ username: 'linus', active: true },
],
});
const user = users.insert(1, { username: 'grace', active: false });
user.keyInParent(); // 1
users.at(2)?.username(); // 'linus'
β removeAt()β
Signature: removeAt(index: number): ItemNode | undefined
Removes, detaches, and returns the node at an index. It returns undefined for an invalid index;
a retained removed node remains independently usable.
const users = array({
username: field(''),
active: field(false),
}, {
initialValue: [
{ username: 'ada', active: true },
{ username: 'grace', active: false },
],
});
const user = users.removeAt(1);
user?.username(); // 'grace'
user?.parent(); // null
users(); // [{ username: 'ada', active: true }]
β moveUp()β
Signature: moveUp(index: number): void
Moves an existing item one position toward the start. Index 0 is a no-op; an invalid index throws
RangeError.
const myForm = form({
usernames: array(field(''), {
initialValue: ['ada', 'grace', 'linus'],
}),
});
myForm.usernames(); // ['ada', 'grace', 'linus']
myForm.usernames.moveUp(2);
myForm.usernames(); // ['ada', 'linus', 'grace']
β moveDown()β
Signature: moveDown(index: number): void
Moves an existing item one position toward the end. The final index is a no-op; an invalid index
throws RangeError.
const myForm = form({
usernames: array(field(''), {
initialValue: ['ada', 'grace', 'linus'],
}),
});
myForm.usernames(); // ['ada', 'grace', 'linus']
myForm.usernames.moveDown(0);
myForm.usernames(); // ['grace', 'ada', 'linus']
β move()β
Signature: move(fromIndex: number, toIndex: number): void
Moves an existing node between two indexes and shifts the intervening nodes. Both indexes must exist; moving to the same index is a no-op.
const myForm = form({
usernames: array(field(''), {
initialValue: ['ada', 'grace', 'linus', 'noa'],
}),
});
const username = myForm.usernames.at(3)!;
myForm.usernames(); // ['ada', 'grace', 'linus', 'noa']
myForm.usernames.move(3, 1);
myForm.usernames(); // ['ada', 'noa', 'grace', 'linus']
myForm.usernames.at(1) === username; // true
β swap()β
Signature: swap(firstIndex: number, secondIndex: number): void
Exchanges two existing item nodes. Both retain their identity and state; equal indexes are a no-op.
const usernames = array(field(''), {
initialValue: ['ada', 'grace', 'linus'],
});
const firstUsername = usernames.at(0)!;
const lastUsername = usernames.at(2)!;
usernames.swap(0, 2);
usernames(); // ['linus', 'grace', 'ada']
usernames.at(0) === lastUsername; // true
usernames.at(2) === firstUsername; // true
β clear()β
Signature: clear(): void
Removes and detaches every current item node.
const usernames = array(field(''), {
initialValue: ['ada', 'grace', 'linus'],
});
usernames.clear();
usernames(); // []
β Update values and reset stateβ
β set()β
Signature: set(value: readonly ItemValue[] | null | undefined): void
Reconciles the complete value, adding or removing nodes as needed. Matching nodes are retained by
index, or by trackBy when configured. Passing null or undefined clears the array.
const users = array({
username: field(''),
active: field(false),
}, {
initialValue: [{ username: 'ada', active: true }],
});
users.set([
{ username: 'linus', active: true },
{ username: 'noa', active: false },
]);
users();
// [{ username: 'linus', active: true }, { username: 'noa', active: false }]
β update()β
Signature: update(updater: (value: ArrayValue) => readonly ItemValue[] | null | undefined): void
Passes the current plain value to a callback and reconciles the callback's complete result.
Returning null or undefined clears the array.
const users = array({
username: field(''),
active: field(false),
}, {
initialValue: [{ username: 'ada', active: true }],
});
users.update(currentUsers => [
...currentUsers,
{ username: 'grace', active: false },
]);
users();
// [{ username: 'ada', active: true }, { username: 'grace', active: false }]
β patch()β
Signature: patch(value: readonly ItemSet[] | null | undefined): void
Equivalent to set(): requires complete item values, adjusts length and order, and reuses nodes
by index or trackBy. Missing nodes detach; new values create nodes from the template/factory.
Reused nodes keep dirty/touched state. Empty arrays and nullish values clear the collection.
This also applies when a containing form or group receives the array through patch().
TypeScript still requires complete items. If untyped data or a cast bypasses this contract,
omitted row properties fall back to the template/factory defaults: age: field(null) produces
age: null. This works for new rows and reused rows, including nested object groups. Defaults
come from item construction, before incoming initialValue data is applied; previous row values
are not defaults. Factories retain the defaults captured when each row was created.
Explicit undefined on a field remains undefined. Object-valued fields are assigned atomically,
without merging their internal properties. An individual row's patch() still preserves omitted
properties. Nullish array values clear arrays; null does not delete declared form properties.
These fallback rules also apply to set() and update(), and to newly constructed items.
See the complete example.
β reset()β
Signatures: reset(): void Β· reset(value: readonly ItemValue[] | null | undefined): void
Without an argument, keeps the current structure and values while recursively clearing interaction state. With a value, it reconciles that value first and then clears interaction state.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
usernames.markAsTouched();
usernames.reset();
usernames.touched(); // false
usernames.reset(['linus', 'noa']);
usernames(); // ['linus', 'noa']
β Validation, interaction, and control methodsβ
β setValidators()β
Signature: setValidators(validators: ValidatorSource<ArrayValue, ArrayNode<TItem>>): void
Replaces the validators owned by the array and immediately evaluates its current aggregate value. It does not replace validators owned by item nodes.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
usernames.setValidators(minLength(3));
usernames.valid(); // false
β 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 array with the requested kind. Descendant errors are
available through allErrors() instead.
const usernames = array(field(''), {
initialValue: ['ada', 'ada'],
validators: uniqueItems,
});
usernames.getError('uniqueItems')?.kind; // 'uniqueItems'
β flush()β
Signature: flush(): void
Immediately commits pending debounced control values throughout the array subtree. It is a no-op when no bound control is debouncing.
const usernames = array(field('', {
debounce: 300,
}), {
initialValue: ['ada', 'grace'],
});
usernames.flush();
usernames.debouncing(); // false
β focus()β
Signature: focus(options?: FocusOptions): void
Focuses the first bound UI control in the array subtree, following DOM order. Standard
FocusOptions can be forwarded.
import { Component } from '@angular/core';
import { array, field, FormNodeDirective } from '@ngblocks/form-nodes';
@Component({
imports: [FormNodeDirective],
template: `
@for (username of usernames; track username) {
<input [formNode]="username" />
}
`,
})
export class UsernamesComponent {
usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
focusFirstUsername() {
this.usernames.focus({ preventScroll: true });
}
}
β markAsTouched()β
Signature: markAsTouched(options?: { skipDescendants?: boolean }): void
Marks the array and its interactive item subtrees as touched and commits their pending control
input for every debounce strategy. Pass skipDescendants: true to skip recursive touching and
committing; the array still commits its own pending input.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
usernames.markAsTouched();
usernames.at(0)?.touched(); // true
usernames.reset();
usernames.markAsTouched({ skipDescendants: true });
usernames.at(0)?.touched(); // false
β markAsUntouched()β
Signature: markAsUntouched(): void
Clears the array's own touched state. A touched descendant can keep the aggregate touched() signal
equal to true; use reset() when the complete subtree should become untouched.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
usernames.markAsTouched({ skipDescendants: true });
usernames.markAsUntouched();
usernames.touched(); // false
β markAsDirty()β
Signature: markAsDirty(): void
Marks the array's own state as dirty. Programmatic value and structural changes do not call this method automatically.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
usernames.markAsDirty();
usernames.dirty(); // true
β markAsPristine()β
Signature: markAsPristine(): void
Clears the array's own dirty state. A dirty descendant can keep the aggregate dirty() signal equal
to true.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
usernames.markAsDirty();
usernames.markAsPristine();
usernames.pristine(); // true
β disable()β
Signature: disable(message?: string): void
Disables the array subtree. An optional short message records why it was disabled.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
usernames.disable();
usernames.disabled(); // true
usernames.enable();
usernames.disable('Locked');
usernames.disabledReasons()[0]?.message; // 'Locked'
β enable()β
Signature: enable(): void
Clears the disabled state created by disable(). A configured or inherited reason can still keep
the array disabled.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
usernames.disable();
usernames.enable();
usernames.enabled(); // true
β markAsReadonly()β
Signature: markAsReadonly(): void
Marks the array subtree readonly, so bound controls cannot commit value changes to it.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
usernames.markAsReadonly();
usernames.writable(); // false
β markAsWritable()β
Signature: markAsWritable(): void
Clears the readonly state created by markAsReadonly(). Other configured or inherited readonly
state can still apply.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
usernames.markAsReadonly();
usernames.markAsWritable();
usernames.writable(); // true
β hide()β
Signature: hide(): void
Marks the array subtree hidden and sets its effective visible() state to false.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
usernames.hide();
usernames.visible(); // false
β show()β
Signature: show(): void
Clears the hidden state created by hide(). Other configured or inherited hidden state can still
keep the array hidden.
const usernames = array(field(''), {
initialValue: ['ada', 'grace'],
});
usernames.hide();
usernames.show();
usernames.visible(); // true
See Dynamic arrays, the reorderable-array recipe, and the shared Node API.
π¨ 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.
β©οΈ 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β
array(): ArrayNode<FieldNode<unknown>> creates [] with a template equivalent to field().
push() adds a fresh field initialized to null; push(value) accepts an unknown value.
Objects remain whole field values, not groups with child properties. Reads return unknown[], so
narrow item values before using them. The first pushed value does not determine later item types.
Prefer an explicit object or node template when the item structure is known, or when you need
validators, options, or a typed value contract. resetToInitial() restores the initial empty array.
createFormPrimitives().array() uses a configured unknown-valued field template. Its missing-value
placeholder remains null even with nullable: false, matching an unspecified configured field.
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: TArray): 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: ArrayValue<TItem>, node: TNode) => void, options?: { injector?: Injector; debounce?: number }): () => void;
Here TNode is the inferred type of this array 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.