Skip to main content

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 withDetails
Write a validator inline({ value }) => ...Inline validators
Declare a reusable typed validatorvalidator<TValue>()Signature
Read node value, tree, or stateValidatorContext<TValue>Context reference
Return success or errorsValidationResultValidation results
Enable rules reactivelyReturn another validatorConditional composition
Replace validators at runtimesetValidators()Validator sources
Assign an aggregate error to a childtargetNodeError ownership
Type custom error dataValidationErrorMapTyped 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​

non-reactive-validator.example.ts
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.

reusable-validator-node.typecheck.ts
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​

MemberDescription
value()Current committed node value with its inferred type.
nodeReadonly signal of the inferred validated node; identical to field.
fieldReadonly 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 stateRead 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​

MemberTypePurpose
valueSignal<TValue>Current committed value
nodeSignal<ValidatorNodeView<TField>>Real node being validated
fieldSignal<ValidatorNodeView<TField>>Real node being validated
rootroot-node signalComplete structural root
parentparent-node signalDirect parent or null
pathpath signalLocation 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.

SignalMeaningExample read
ctx.node().submitting()The node or an ancestor form is submittingctx.node().submitting()
ctx.node().touched()Interaction marked the node touchedctx.node().touched()
ctx.node().untouched()The node remains untouchedctx.node().untouched()
ctx.node().dirty()Modification was recordedctx.node().dirty()
ctx.node().pristine()No modification was recordedctx.node().pristine()
ctx.node().disabled()The node is excludedctx.node().disabled()
ctx.node().enabled()The node participates normallyctx.node().enabled()
ctx.node().disabledReasons()Active disabling causesctx.node().disabledReasons()
ctx.node().readonly()Consumers should prevent editingctx.node().readonly()
ctx.node().writable()Consumers may permit editingctx.node().writable()
ctx.node().hidden()Consumers should omit the nodectx.node().hidden()
ctx.node().visible()Consumers should display the nodectx.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:

ResultMeaning
null, undefined, or voidSuccess
stringOne 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 objectsSeveral errors, preserving their order
Another synchronous validatorConditional composition
An array of synchronous validatorsConditional 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.

Keep returned arrays homogeneous

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​

TypePurpose
ValidationErrorBase { kind, message? } error shape.
ValidationResultSynchronous 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.
ValidationErrorMapExtensible 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.