validator()
Attach a synchronous validator directly to a field, form, or array. The consuming node contextually infers its value type:
import { field, form, required, validator } from '@ngblocks/form-nodes';
const adult = validator<number | null>(({ value }) => {
const age = value();
return age !== null && age < 18
? { kind: 'adult', minimumAge: 18, actual: age }
: null;
});
const myForm = form({
age: field<number>(null, [required, adult]),
});
π§ API mapβ
| I want to⦠| Start with | Details |
|---|---|---|
| Write a validator inline | ({ value }) => ... | Inline validators |
| Declare a reusable typed validator | validator<TValue>() | Signature |
| Read node value, tree, or state | ValidatorContext<TValue> | Context reference |
| Return success or errors | ValidationResult | Validation results |
| Enable rules reactively | Return another validator | Conditional composition |
| Replace validators at runtime | setValidators() | Validator sources |
| Assign an aggregate error to a child | targetNode | Error ownership |
| Type custom error data | ValidationErrorMap | Typed custom errors |
β Inline validatorsβ
Use an inline callback when a rule belongs to one node and its value type can be inferred from that
node. Use validator() when the rule is declared separately and therefore has no consuming node
from which TypeScript can infer its value.
β validator()β
Use validator<TValue>() when declaring a reusable validator separately from its consuming node:
import { field, form, required, validator } from '@ngblocks/form-nodes';
export const adult = validator<number | null>(({ value }) => {
const age = value();
return age !== null && age < 18
? { kind: 'adult', minimumAge: 18, actual: age }
: null;
});
const myForm = form({
age: field<number>(null, [required, adult]),
});
By default, the helper returns the original function with normal signal tracking. Set
{ reactive: false } to track the node value while sampling external signals without subscribing
to their changes. Neither mode requires an injector or eagerly executes the callback.
TValue must match the node value, including null or undefined when admitted by the field.
A validator declared as validator<number>() fits field(0) or
field.strict(0); nullable fields need a validator that handles null.
Forms and arrays use their aggregate models.
β Signatureβ
validator<TValue, TField extends AnyNode = AnyNode>(
validate: NoInfer<(() => any) | ComposableValidator<TValue, ValidatorOwner<TField>>>,
options?: { reactive?: boolean },
): ComposableValidator<TValue, TField>;
TValue is the exact committed value type of the field, form, group, or array. The return value is
the original validate function by identity unless reactive is false, which returns a wrapper. TField is inferred from the consuming primitive
when the helper is inline. ValidatorOwner gives a standalone helper the common node union
when no concrete owner is supplied. Omit helper type arguments to infer both types inline.
Parameterless callbacks can reference their class form, including returning another synchronous validator, without a return annotation. Their accepted return type is intentionally unchecked; callbacks receiving a context still check their results. A fallback overload retains inference from explicitly annotated standalone callback contexts. See Self-referencing validators.
β Validate on value changes without tracking external signalsβ
import { signal } from '@angular/core';
import { field, form, validator } from '@ngblocks/form-nodes';
const minimumAge = signal(18);
let executions = 0;
const profile = form({
age: field(16, validator(({ value }) => {
executions++;
return value() !== null && value()! < minimumAge() ? { kind: 'minimumAge' } : null;
}, { reactive: false })),
});
profile.age(); // 16
profile.invalid(); // true
if (!profile.invalid() || executions !== 1) throw new Error('The initial value must be validated.');
minimumAge.set(15);
profile.invalid(); // true
if (!profile.invalid() || Number(executions) !== 1) throw new Error('External changes must not trigger validation.');
profile.age.set(17);
profile.valid(); // true
if (!profile.valid() || Number(executions) !== 2) throw new Error('A new value must sample the latest minimum.');
reactive defaults to true. With false, the owning node value remains a dependency even if
its callback never reads value(). Signal reads in the callback and in returned validator
compositions are untracked. Conditions and error messages evaluated in that composition follow
the same policy. The next execution samples their latest values. Each helper call has its own
policy, so wrapping a shared function does not change other uses of that function.
Normal lifecycle triggers still apply: replacing validators, restoring availability, or another
tracked validator invalidating the node's shared validation computation can execute the callback
again. This is not a once-only validator or a promise that only value changes can execute it.
Public value equality and buffered input retain their existing behavior. This option is for
synchronous validators; passing an asyncValidator() to it throws. Use the asynchronous
validator's params option to control its dependencies.
untracked() prevents dependency registration, but still evaluates signals. This option does
not make self-referential validation reads safe.
β Value type and inferenceβ
Separately declared helpers preserve TValue on the generic node returned by ctx.field() and
ctx.node(). Calling that node or reading its value() signal returns the same type as
ctx.value(); the equivalent api.value() and $api.value() paths also preserve it.
For validator<string | null>(), all these reads have type string | null. The primitive kind
and child names remain unspecified until a concrete owner is supplied or inferred inline.
import { field, form, validator } from '@ngblocks/form-nodes';
const notBlank = validator<string | null>((ctx) => {
const node = ctx.field();
const currentValue = node.value();
const typedValue: string | null = currentValue;
// @ts-expect-error The declared value type cannot be assigned to a number.
const numericValue: number = currentValue;
void numericValue;
return typedValue?.trim() ? null : { kind: 'blankName' };
});
const profile = form({
name: field('', [notBlank]),
});
void profile;
When validator() is declared separately, there is no consuming node from which TypeScript can
infer TValue. If the generic is omitted, value() is therefore unknown and must be narrowed:
const notBlank = validator(({ value }) => {
const currentValue = value(); // unknown
return typeof currentValue === 'string' && currentValue.trim().length > 0
? null
: { kind: 'blank' };
});
Specify the exact node value type when the reusable rule belongs to a known domain:
const adult = validator<number | null>(({ value }) => {
const age = value(); // number | null
return age !== null && age < 18 ? { kind: 'adult' } : null;
});
The type must include null for nullable fields. Inline callbacks and inline helpers infer the validated node as well as its value.
See Inline node inference.
β Validator contextβ
| Member | Description |
|---|---|
value() | Current committed node value with its inferred type. |
node | Readonly signal of the inferred validated node; identical to field. |
field | Readonly signal returning the validated field, form, group, or array. |
root() | Complete structural root; identical to node root navigation. |
parent() | Direct parent node, or null at the root. |
path() | Reactive path from the root. |
| Node state | Read interaction and availability signals through ctx.node() or ctx.field(). |
The context and its signals are stable. By default, signal reads while the validator executes
become reactive dependencies. With reactive: false, only the owning value is tracked by the helper.
The returned nodes are type-restricted views of the original objects. Validation errors/status,
pending/debouncing state, validation-derived constraints (required, min, max, length bounds,
and pattern), metadata/validator-resolution queries, and mutation methods are unavailable.
The restriction follows $api, parent/root/form navigation, children, array items, and traversal
callbacks. It also applies to parent<TParent>(); the generic describes structure and cannot
restore the full API. A known child actually named valid or set remains a readable child. For an unspecified
child dictionary such as parent<FormNode<any>>(), use $api.get() or $api.children; its
arbitrary direct string index is omitted so it cannot expose validation outputs.
Use values to express cross-field rules and return errors, optionally with a targetNode from
the context. Perform node mutations in application actions or configure, outside validation.
These are TypeScript restrictions: an external reference or an explicit unsafe cast can still
create a cycle at runtime. ValidatorNodeView is an internal helper name, not a package import.
π Context referenceβ
| Member | Type | Purpose |
|---|---|---|
value | Signal<TValue> | Current committed value |
node | Signal<ValidatorNodeView<TField>> | Real node being validated |
field | Signal<ValidatorNodeView<TField>> | Real node being validated |
root | root-node signal | Complete structural root |
parent | parent-node signal | Direct parent or null |
path | path signal | Location from the root |
β Value and nodeβ
β valueβ
Signature: value: Signal<TValue>
Call value() to read the committed value. The read creates a dependency, so validation runs
again when the value changes.
validator<string>(({ value }) => value().trim() ? null : { kind: 'blank' });
β nodeβ
Signature: node: Signal<ValidatorNodeView<TField>>
The readonly signal of the validated node, identical to field. Prefer this name when the owner
can be a form, group, or array. Both aliases retain the same inferred node type.
See Inline node inference.
β fieldβ
Signature: field: Signal<ValidatorNodeView<TField>>
A stable readonly signal returning the validated node; never null. This is the exact same signal
as node. Inline primitive validators infer the concrete field, form, group, or array, including
aggregate children and array items. A separately declared validator defaults to the common node
API union; primitive-specific operations then require narrowing. Explicit TField context types
retain their value and child types through a recursive read-only view.
context.field() returns the node. Read its committed value with context.value(), which
preserves the inferred value type. Call context.field()() when reading through the node. Reading only field() tracks node identity, which
stays stable across value changes and attachment or detachment. Read a returned node's value or
state signal when validation should depend on that state.
See Navigation inside validators.
validator<string>(({ field }) => field().value() ? null : { kind: 'blank' });
β node().$apiβ
Access the node API through ctx.node().$api or ctx.field().$api. Its type follows the validated
node, so inline validators retain the concrete primitive API. There is no direct ctx.$api property.
For ordinary state reads, use the node directly, such as ctx.node().dirty().
See API access for aliases and child-name collisions.
β Tree navigationβ
β node().form()β
Use context.node().form() (or context.field().form()) for the nearest explicit form workflow.
It returns null when no form owns the node. There is no flat context.form property.
β rootβ
Use context.root() as a shortcut for context.node().root() to read the complete structural root.
It is the same readonly signal as context.node().$api.root, including when a child is named root.
It never returns null: standalone nodes return themselves. Attachment and detachment update the
signal reactively. The returned node has the same readonly validation view as node navigation.
β parentβ
Default type: Signal of a form, group, or array API, or null.
The direct parent, or null when the validated node is a root. A parent is always a form, group,
or array. Common node members are available directly; primitive-specific operations need narrowing.
An explicit generic may include null or undefined, as array index types often do. The result
is the restricted view of NonNullable<TParent> | null, with no undefined member:
ctx.parent<PageForm['roles'][number]>();
ctx.parent<(typeof this.pageForm.roles)[number]>();
ctx.parent<typeof this.pageForm.roles[0]>();
These type arguments describe the immediate parent's structure; they do not select an array item. See the complete component example.
validator<string>(({ parent }) => parent() ? null : { kind: 'mustHaveParent' });
β pathβ
Signature: path: Signal<readonly string[]>
Property names and array indexes locating the node from its root. Array indexes are strings. A validator that reads the path can rerun when an array item moves.
validator<string>(({ path }) => path().length > 3 ? { kind: 'tooDeep' } : null);
β Stateβ
β state signalsβ
Read interaction and availability state through ctx.node() or its alias ctx.field(). These signals are not direct context
properties. The same access works in inline validators and reusable helpers.
| Signal | Meaning | Example read |
|---|---|---|
ctx.node().submitting() | The node or an ancestor form is submitting | ctx.node().submitting() |
ctx.node().touched() | Interaction marked the node touched | ctx.node().touched() |
ctx.node().untouched() | The node remains untouched | ctx.node().untouched() |
ctx.node().dirty() | Modification was recorded | ctx.node().dirty() |
ctx.node().pristine() | No modification was recorded | ctx.node().pristine() |
ctx.node().disabled() | The node is excluded | ctx.node().disabled() |
ctx.node().enabled() | The node participates normally | ctx.node().enabled() |
ctx.node().disabledReasons() | Active disabling causes | ctx.node().disabledReasons() |
ctx.node().readonly() | Consumers should prevent editing | ctx.node().readonly() |
ctx.node().writable() | Consumers may permit editing | ctx.node().writable() |
ctx.node().hidden() | Consumers should omit the node | ctx.node().hidden() |
ctx.node().visible() | Consumers should display the node | ctx.node().visible() |
State reads become dependencies by default; reactive: false samples them without tracking.
β Validation resultsβ
Runtime normalization keeps objects whose kind is a readable string, including ''. It ignores
malformed objects, non-string primitives, nested error arrays, and accidentally returned nodes, emitting a
warning only in Angular development mode. null and undefined are ignored silently. Arrays
retain valid errors in order. Strings become { kind: 'custom', message }, including empty strings.
getError('custom') returns the first matching error; use an explicit kind to distinguish rules.
See Returning messages. Ignored results
contribute no errors and do not block the form. See
Malformed validator results.
A synchronous validator may return:
| Result | Meaning |
|---|---|
null, undefined, or void | Success |
string | One error with kind: 'custom' and the returned message, including '' |
{ kind, ...data } | One validation error; a string or numeric kind is accepted and exposed as a string |
| An array of strings and/or error objects | Several errors, preserving their order |
| Another synchronous validator | Conditional composition |
| An array of synchronous validators | Conditional composition of several rules |
After nullish and malformed entries are removed, a returned array cannot mix validators and valid errors. Async validators cannot be returned through this composition mechanism.
An empty error array is also successful. Every error requires a discriminating kind; it may add
a human-readable message and arbitrary structured data.
π‘ Conditional compositionβ
A synchronous validator can return another synchronous validator, or an array containing only validators and nullish entries:
const requireAdult = signal(false);
const adultWhenRequired = validator<number | null>(() => {
return requireAdult() ? min(18) : null;
});
const myForm = form({
age: field<number>(null, [adultWhenRequired]),
});
The outer callback tracks requireAdult(). When it changes, Form Nodes resolves the selected rule
against the same context.
After nullish entries are removed, a returned array must contain either errors or validatorsβnot both. A mixed array throws because its intent is ambiguous.
Put asyncValidator() directly in the node's validator source; returning it from synchronous
composition throws. Circular composition is rejected, and composition deeper than 100 levels
throws instead of recursing indefinitely.
β Validator sourcesβ
Node options, positional validator arguments, and setValidators() accept one validator or a
readonly array. Nullish array entries are ignored:
myForm.age.setValidators([
required,
minimumAgeEnabled() ? min(18) : null,
]);
That boolean condition is evaluated when setValidators() runs. For a condition that follows a
signal over time, return validators from a reactive validator instead:
myForm.age.setValidators([
validator<number | null>(() => {
return minimumAgeEnabled() ? [required, min(18)] : null;
}),
]);
validators() exposes the normalized readonly array. Replacing validators revalidates the current
value without changing dirty or touched state.
π¨ Error ownershipβ
Validators normally return errors without targetNode. Before exposure, the node assigns itself as
the target. errors() reads only errors owned by the current node; allErrors() additionally
traverses descendants.
const error = myForm.age.errors()[0];
error.kind; // 'adult'
error.targetNode === myForm.age; // true
For a cross-field rule, an aggregate validator can assign the error to the descendant that should display it:
const confirmation = field('');
const passwordsMatch = validator<{ password: string | null; confirmation: string | null }>(
({ value }) => value().password === value().confirmation
? null
: {
kind: 'passwordMismatch',
message: 'Passwords must match.',
targetNode: confirmation,
},
);
const myForm = form({
password: field(''),
confirmation,
}, {
validators: passwordsMatch,
});
Omit targetNode when the error belongs to the node being validated. formNode is reserved for
errors created by a concrete rendered control binding.
Use getError(kind) for the first own error of a kind. Applications and reusable packages can
augment ValidationErrorMap so custom kinds expose strongly typed data. See
Errors and validation status.
π¨ Typed custom errorsβ
Custom properties are unknown by default. Applications and packages can augment the registry:
declare module '@ngblocks/form-nodes' {
interface ValidationErrorMap {
minimumAge: ValidationError & {
readonly kind: 'minimumAge';
readonly minimumAge: number;
readonly actual: number;
};
}
}
const error = myForm.age.getError('minimumAge');
error?.minimumAge; // number | undefined
π Execution behaviorβ
- Validators execute synchronously and reactively, in declaration order.
- Replacing validators immediately revalidates the committed value.
- Validation does not mark a node dirty or touched.
- Disabled, readonly, or hidden nodes skip validation and resume it when interactive again.
- Synchronous errors prevent async validators on that node from starting until they are resolved.
π Relevant public typesβ
| Type | Purpose |
|---|---|
ValidationError | Base { kind, message? } error shape. |
ValidationResult | Synchronous success, a message or error object, or an array of both. |
ValidatorContext<TValue> | Complete synchronous callback context. |
Validator<TValue> | Basic synchronous validation function. |
ComposableValidator<TValue> | Validator that can return other validators conditionally. |
ValidatorSource<TValue, TField> | One validator or a readonly validator array with nullish entries. |
ValidationErrorMap | Extensible registry used by typed getError(). |
See Validation, Built-in validators, and
asyncValidator().
For custom helper signatures and explicit return-type alternatives, see troubleshooting circular type inference.