Skip to main content

Validation

The executable validation example checks field and form error ownership through both failing and valid states.

For typed sibling access within an array row, use the configure option. It receives the inferred children after construction; reusable validators can alternatively declare an explicit ctx.parent<TParent>() contract.

Pass validators in a node's options or as the positional validator argument:

import { field, minLength, required } from '@ngblocks/form-nodes';

const myForm = form({
name: field('', {
validators: [required, minLength(2)],
}),
alias: field('', [required]),
});

Validators may be a single validator or an array. null and undefined array entries are ignored. The validators() signal returns the normalized list of directly registered functions. It does not execute those functions or expand returned compositions by default.

ctx.root() is a shortcut for ctx.node().root() in inline validators, validator(), and all asyncValidator() callbacks. It returns the same readonly node view and tracks structural attachment and detachment. Standalone nodes return themselves; nested forms resolve the outermost root.

validator-root.example.ts
import { field, form, validator } from '@ngblocks/form-nodes';

const requireAttachment = validator<string | null>(({ root }) => {
return root().$api.nodeType() === 'field' ? { kind: 'standalone' } : null;
});
const name = field('Mark', requireAttachment);
if (!name.hasError('standalone')) throw new Error('A standalone field is its own root.');

const profile = form({ name });
if (profile.invalid()) throw new Error('Root navigation must react to attachment.');
profile.name(); // 'Mark'

profile.name.setValidators(({ root, node }) => {
if (root() !== node().root()) throw new Error('The root shortcut must match node navigation.');
return null;
});
if (profile.invalid()) throw new Error('Inline validators share the same root shortcut.');

Validator arguments and return values

The same ValidatorSource<TValue, TNode> contract applies to these inputs:

PrimitivePositional validatorsOptions
field()field(initialValue, validators, options?)field(initialValue, { validators })
form()form(definition, validators, options?)form(definition, { validators })
group()group(definition, validators, options?)group(definition, { validators })
array()array(template, validators, options?) or array(template, initialValue, validators, options?)array(template, { initialValue, validators })

The argument itself accepts one callback or a readonly list of callbacks, including registered asyncValidator() functions. Nullish list entries are ignored. setValidators() accepts this same source. This also applies to strict/nullable fields and configured primitives.

A synchronous callback receives a typed ValidatorContext<TValue, ValidatorApi<TValue>, TNode>. Its supported result contract is ComposableValidationResult<TValue, TNode>:

Returned valueMeaning
null, undefined, or implicit fallthrough (void)No errors
string, including ''An error with kind: 'custom' and that message
ValidatorError
{ kind: string | number; message?: string; targetNode?: AnyNode }
One validation error. Omit targetNode to target the current node, or specify another node.
A readonly array of message strings and error objectsSeveral errors in order; an empty array succeeds
A synchronous validatorEvaluate that validator using the same context
A readonly array of synchronous validators and nullish entriesEvaluate those validators using the same context

ValidationResult describes the first four rows; ComposableValidationResult<TValue, TNode> also includes the composition rows. Returned arrays must not mix validators and errors. A bare number or boolean is not an error result. A raw Promise or Observable is not a synchronous result: register asyncValidator() to handle asynchronous work and its lifecycle.

Typed context, flexible declaration return

Declaration callbacks have an any return type in TypeScript to allow reads from their own initializing form or group. The context, sibling fields, node methods, and model values remain typed. any does not make other runtime results valid, and it means malformed returns may compile.

For explicit result checking, annotate the callback with : ValidationResult, or with : ComposableValidationResult<TValue, TNode> when composing validators. The context-taking validator() helper also checks results. For returned inline validators, wrapping the outer callback in validator() restores their contextual parameter types. A checked self-referencing callback may still need the return annotation to break TypeScript's inference cycle.

declaration-validator-results.ts
import { field, form, validator, type ValidationResult } from '@ngblocks/form-nodes';

const booking = form({
endDate: field<string>(null),
startDate: field<string>(null, ({ value }) => {
const endDate = booking.endDate();
return endDate && !value()
? { kind: 123, message: 'Enter a start date.' }
: null;
}),
reference: field('', ({ value }): ValidationResult => {
return value() ? null : { kind: 'missingReference', message: 'Enter a reference.' };
}),
});

booking.endDate.set('2026-09-10');
booking.startDate.getError('123')?.kind; // '123'
if (booking.startDate.getError('123')?.message !== 'Enter a start date.') {
throw new Error('Self-referencing validators must expose normalized numeric errors.');
}
booking.startDate.set('2026-09-09');
if (booking.startDate.invalid()) throw new Error('Sibling validation must recover after editing.');
booking.reference.set('BOOK-1');
if (booking.invalid()) throw new Error('All fields should now be valid.');

// The checked helper preserves the context of validators returned by another validator.
booking.reference.setValidators(validator(({ value }) => {
return value() ? null : [({ value: current }) => current() ? null : { kind: 'missingReference' }];
}));
booking.reference.set('');
if (!booking.reference.hasError('missingReference')) {
throw new Error('Checked composition must preserve its runtime behavior.');
}
booking.resetToInitial();
if (booking.startDate.invalid()) throw new Error('Reset must restore the sibling condition.');

Numeric error identifiers

An input error such as { kind: 123, message: 'Missing' } becomes an exposed error with kind: '123'. Use getError('123') and hasError('123'); public error kinds remain strings. The conversion uses JavaScript String(kind), including 0, negative/decimal numbers, NaN, and infinities. Numeric errors are shallow-copied with their enumerable data, message, and target; the original numeric object is not mutated. Existing string-kind errors retain their identity. This normalization also applies to asynchronous validation results.

✅ Reading validation state

name.valid();
name.invalid();
name.pending();
name.validationStatus(); // 'valid', 'invalid', or 'unknown'
name.errors();
name.getError('minLength');

errors() contains errors owned directly by the current node. allErrors() includes the complete descendant subtree, which matters for forms and arrays:

profile.errors(); // profile-level errors only
profile.allErrors(); // profile and descendant errors

Every exposed error contains kind and targetNode; built-in errors also contain a default or configured message and constraint-specific data. getError() infers known built-in error data from its kind.

See Errors and validation status for ownership, aggregate ordering, typed custom kinds, binding errors, and the exact status table.

Built-in validators can replace their normal structured error through error. The option accepts one error, several errors, or a reactive function receiving the validator context:

const age = field(16, [
min(18, { error: ({ value }) => ({ kind: 'minimumAge', actual: value(), minimum: 18 }) })
]);

The replacement is evaluated only while the built-in rule fails. It cannot be combined with message; return an empty array, null, or undefined when the failed rule should currently contribute no error. See Built-in validator custom errors.

✅ Custom validators

A synchronous validator receives a stable context with its value signal and a read-only validation view of the node. Validation results, metadata queries, and mutations are omitted recursively, including from parent<TParent>(), $api, and descendants. Use values for cross-field conditions. The same restriction applies to asynchronous contexts. See the context reference.

import { field, 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, message: 'You must be 18 or older.' }
: null;
});

const age = field<number>(null, [adult]);

Return null, undefined, or nothing for success; return one error or an array of errors for failure. validator() provides a typed reusable authoring context and tracks signals by default. Set { reactive: false } to keep value-triggered validation without tracking external reads; see the helper reference.

Validators can inspect value, node, field, parent, and path. Read state through the node, such as ctx.node().touched() or ctx.field().dirty(). These reads become reactive dependencies, as do external constraints:

const minimumAge = signal(18);

const age = field<number>(null, [({ value }) => {
const actual = value();
return actual !== null && actual < minimumAge()
? { kind: 'minimumAge', actual, min: minimumAge() }
: null;
}]);

✅ Form and cross-field validation

Attach a validator to a form to validate its aggregated value:

const passwords = form({
password: field(''),
confirmation: field(''),
}, {
validators: [({ value }) => value().password === value().confirmation
? null
: { kind: 'passwordMismatch', message: 'Passwords must match.' }],
});

For a field-level confirmation rule, equalTo() accepts a reactive source:

const password = field('');
const myForm = form({
password,
confirmation: field('', [equalTo(() => password())]),
});

🔗 Referencing the form from its own validators

A validator declared on a class property can read this.myForm, including sibling fields, without a return annotation or an explicit form type. Block and expression callbacks may return errors directly, a validator, or an array of synchronous validators. Both positional validators and the validators option support this pattern, including strict fields and configured primitives. Editors also suggest configuration keys when you start an options object such as field('', {}).

This signup form checks that both passwords match. Changing the password revalidates its confirmation. Keep the sibling read inside the callback so it runs after myForm is assigned.

self-referencing-validation.example.ts
import { equalTo, field, form } from '@ngblocks/form-nodes';

class SignupForm {
myForm = form({
password: field('secret'),
confirmation: field('', [() => equalTo(this.myForm.password())]),
});
}

const { myForm } = new SignupForm();
myForm.confirmation.hasError('equalTo'); // true
if (!myForm.confirmation.hasError('equalTo')) throw new Error('Passwords must match.');

myForm.confirmation.set('secret');
myForm.valid(); // true
if (!myForm.valid()) throw new Error('Matching passwords must be valid.');

myForm.password.set('new-secret');
myForm.confirmation.hasError('equalTo'); // true
if (!myForm.confirmation.hasError('equalTo')) throw new Error('Password changes must revalidate confirmation.');

The same syntax works inside validator() and the callback signature of asyncValidator():

self-referencing-validation-helpers.typecheck.ts
import { Component } from '@angular/core';
import { asyncValidator, equalTo, field, form, validator } from '@ngblocks/form-nodes';

@Component({ selector: 'app-signup', template: '' })
export class SignupComponent {
form = form({
password: field(''),
confirmation: field('', [validator(() => equalTo(this.form.password()))]),
username: field('', [asyncValidator(async () => {
return this.form.username() === 'admin' ? { kind: 'reserved' } : null;
})]),
});
}

Declaration callbacks have an intentionally unchecked return type, with or without a context. Inside the helpers, parameterless callbacks are unchecked and context-taking callbacks retain checked context and result types. The runtime contract still applies: synchronous callbacks return errors, successful results, or synchronous validators; asynchronous callbacks return Promise-like or Observable-like results. An asynchronous callback must return the validation result, not merely test whether a validator function exists.

Mixing synchronous and asynchronous rules is safe during class initialization. Initial asynchronous setup waits until construction finishes, or until you read validation state. Synchronous errors prevent asynchronous execution, including unconditional errors such as () => ({ kind: '' }).

This exception includes overloaded functions callable without arguments. For example, uniqueItems() returns a validator whose array-value compatibility is checked, while passing uniqueItems directly can match the unchecked parameterless branch. A deferred callback's returned validator is likewise not checked against the consuming field's value type.

💬 Returning messages

Return a string for a simple error message. Form Nodes converts it to { kind: 'custom', message }, so error queries keep their usual object shape.

string-validation.example.ts
import { field, form } from '@ngblocks/form-nodes';

const myForm = form({
name: field('admin', ({ value }) => value() === 'admin' ? 'Choose another name' : null),
});

myForm.name.getError('custom')?.message; // 'Choose another name'
if (myForm.valid() || myForm.name.getError('custom')?.message !== 'Choose another name') {
throw new Error('A message must become a custom validation error.');
}

myForm.name.set('Alex');
myForm.valid(); // true
if (!myForm.valid() || myForm.allErrors().length !== 0) {
throw new Error('Returning null must clear the error.');
}

This works in inline validators, validator(), asynchronous validators, and onError. Arrays can mix message strings and error objects; order and duplicate kinds are preserved. getError('custom') returns the first matching error. Return an object with a specific kind when you need to identify a particular rule or target another node.

Every string, including '' and whitespace-only messages, represents an error. Messages are preserved exactly; return null or undefined for success.

🛡️ Malformed validator results

Error objects need a readable string or numeric kind; an empty string is allowed and numeric kinds become strings. Malformed objects, non-string primitives, and returned form nodes are ignored with a warning in Angular development mode. null and undefined remain silent success results. Arrays retain valid errors and discard invalid entries. This applies to synchronous results, resolved asynchronous results, and asynchronous onError results.

invalid-validation-results.example.ts
import { field, form } from '@ngblocks/form-nodes';

const myForm = form({
name: field('', () => ({ message: 'Missing kind' })),
});

myForm.valid(); // true; the malformed result is ignored, with a development warning
if (!myForm.valid()) throw new Error('Malformed results must not block the form.');

myForm.name.setValidators(() => [{ kind: 'custom' }, {}]);
myForm.name.errors().length; // 1; only the valid error is kept
if (myForm.name.errors().length !== 1 || !myForm.name.hasError('custom')) {
throw new Error('Valid errors must survive filtering.');
}

Ignored results do not block the form. The warning identifies a mistake in the validator, not an error in the user's input. Nodes returned accidentally, such as ctx.parent(), are ignored without being called as composed validators. Exceptions thrown by your callback and the existing composition guards still follow their normal error handling.

✅ Conditional validators

A validator may return another synchronous validator or an array of validators. This supports reactive conditions without rebuilding the node:

const requireName = signal(false);

const myForm = form({
name: field('', {
validators: [() => requireName() ? [required, minLength(2)] : null],
}),
});

Use setValidators() when the configured validator collection itself must be replaced.

Returned validators can be nested and all receive the same stable context. Signals read by the outer condition or any returned validator remain reactive dependencies by default. A composition inside validator(callback, { reactive: false }) samples those reads without tracking them. A returned array must contain validators or validation errors after nullish entries are removed; mixing both is rejected as ambiguous.

Configure asyncValidator() directly in the node's validator list. It cannot be returned from a synchronous validator because the node must establish its cancellation and ownership lifecycle without executing arbitrary synchronous callbacks.

🧩 Evaluation model

Synchronous validation is lazy. Signal changes invalidate its result, and validators rerun when errors(), valid(), invalid(), or validationStatus() is next consumed. Templates and other reactive consumers observe that recomputation automatically.

The context and its signal properties remain stable between executions. Reading application signals directly inside the callback is sufficient; an extra computed() wrapper is unnecessary.

See Advanced behavior and edge cases for composition limits, execution timing, and async dependency details.

✅ Constraint metadata

Built-in constraints expose metadata for UI bindings:

age.min();
age.max();
name.minLength();
name.maxLength();
name.pattern();
name.required();

[formNode] forwards applicable metadata to native and compatible custom controls.

See Built-in validators and Validator messages. For reusable helpers, context types, result shapes, and conditional composition, see the validator() reference.

✅ Inspect resolved validators

Use validators({ resolve: true }) to inspect the final function references reached through synchronous compositions. Use hasValidator(validator, { resolve: true }) to query the same list. Omitting the options, or passing { resolve: false }, keeps the directly registered list. validators remains an Angular Signal whose ordinary call returns that registered list.

import { computed, signal } from '@angular/core';
import { field, form, required } from '@ngblocks/form-nodes';

const requireName = signal(true);
const optionalName = () => null;
const nameRules = () => requireName() ? required : optionalName;
const profile = form({
name: field('', [nameRules]),
});
const includesRequired = computed(() => profile.name.hasValidator(required, { resolve: true }));

profile.name.validators(); // [nameRules]
profile.name.validators({ resolve: true }); // [required]
profile.name.hasValidator(required); // false
includesRequired(); // true

if (profile.name.validators()[0] !== nameRules
|| profile.name.validators({ resolve: true })[0] !== required
|| profile.name.hasValidator(required) || !includesRequired()) {
throw new Error('Resolved queries must follow returned validators without changing registration.');
}

profile.name.set('Marco');
profile.name.hasError('required'); // false
includesRequired(); // true: a passing validator is still present

if (profile.name.hasError('required') || !includesRequired()) {
throw new Error('Resolved presence must not depend on whether the validator fails.');
}

requireName.set(false);
profile.name.validators({ resolve: true }); // [optionalName]
includesRequired(); // false

if (profile.name.validators({ resolve: true })[0] !== optionalName || includesRequired()) {
throw new Error('Resolved queries must react to changes in composition dependencies.');
}

Resolution follows returned functions and arrays of functions recursively, preserving declaration order and duplicates. A composing function is replaced by the leaves it returns. A function returning errors, null, undefined, or an empty array is itself a leaf and remains in the list, even when successful or conditionally skipped. This API describes resolved references, not which constraints are active; for example, use required() for active required metadata.

Resolution cannot discover calls hidden inside a wrapper. () => required exposes required, whereas context => required(context) exposes only the wrapper. Factory-created validators still compare by reference: retain the returned function when you need to query it later.

Resolving executes synchronous validator functions with the node's normal validation context. Queries and synchronous validation share a cached evaluation, including errors and metadata, so reading both does not duplicate executions for unchanged dependencies. Signals read during that evaluation participate in tracking. Resolved queries can therefore surface the same exceptions as validation, including invalid or circular compositions.

An explicit resolved query also evaluates a disabled, hidden, or readonly node on demand. It does not enable validation for that node: its reported errors, validity, and interaction state retain the normal suppression rules. Ordinary queries do not execute validators. Directly registered async validators remain references in the resolved list; inspection does not start or restart their async work. Returning an async validator from a synchronous composition remains unsupported. Neither mode searches descendants or validators belonging to external controls.

Self-referencing when conditions

Built-in validators and asyncValidator accept parameterless when callbacks referencing the form being declared, including through computed signals declared later in the class. The form and computed retain their inferred types without return annotations.

profile-model.ts
import { computed } from '@angular/core';
import { field, form, required } from '@ngblocks/form-nodes';

class ProfileModel {
form = form({
other: field<number>(23, [required]),
subType: field<string>(null, [required({ when: () => this.isTypeVisible() })]),
});

isTypeVisible = computed(() => (this.form.other() ?? 0) > 30);
}

const model = new ProfileModel();
model.isTypeVisible(); // false
if (model.form.subType.required() || !model.form.valid()) throw new Error('The hidden subtype must be optional.');
model.form.other.set(31);
model.isTypeVisible(); // true
if (!model.form.subType.required() || !model.form.invalid()) throw new Error('The visible subtype must be required.');
model.form.other.set(23);
if (model.form.subType.required() || !model.form.valid()) throw new Error('The condition must remain reactive.');

Parameterless conditions intentionally have unchecked return types; always return a boolean. Callbacks receiving a context, such as when: ({ value }) => value() !== null, keep a typed context and a checked boolean result. This change applies to when; reactive bounds, dates, lists, and message callbacks retain their existing signatures.

Async validators with when defer their initial automatic condition evaluation until the class initializer finishes. Reading validation state or explicitly calling validate() can start that work sooner; do so only after initialization. Disabling a condition cancels its pending work and releases dependency tracking. Reenabling it starts a fresh execution even when the field value has not changed. These rules apply inside and outside Angular injection contexts.

Troubleshooting circular type inference

A class form can refer to a validator or condition that refers back to the same form. TypeScript may then report TS7022, TS7023, or TS7024: a property or function implicitly has type any because it is referenced directly or indirectly in its own initializer or return expression. A common cycle is form → condition callback → computed → form. Moving the computed above the form does not necessarily resolve a type-inference cycle.

What the library handles

Inline declaration validators (with or without a context), parameterless callbacks passed to validator() and asyncValidator(), requiredIf conditions, and parameterless when conditions support these self-references without explicit return annotations. Their callback return types are intentionally unchecked. The expected runtime result still applies: conditions should return booleans, synchronous validators should return supported validation results, and async validators should return supported Promise-like or Observable-like results. Context-taking helper callbacks retain their checked contracts.

A custom helper with its own strict callback signature can reintroduce the cycle. Reactive numeric limits, date limits, allowed-value lists, and message callbacks also retain their existing checked signatures; they are not covered by the parameterless-condition relaxation.

Fix application code with an explicit result type

Prefer an accurate return annotation at one point in the cycle. For example:

  • Annotate the computed callback: computed((): boolean => ...).
  • Annotate the condition: requiredWhen((): boolean => ...).
  • For a custom method, declare its actual result, such as isTypeVisible(): boolean.

Use the actual result type for other callbacks: a numeric constraint may return number | undefined, a message may return string | undefined, and a synchronous custom validator can declare ValidationResult. Such annotations provide a type boundary while keeping return-value checking. Avoid annotating the entire form as any, since that discards useful child and value types.

Context-taking validators that read their owning group

Direct declaration callbacks now support the sibling-reading ternary with either null or undefined, without annotating the callback or group. The result contract explains the typed context and deliberately unchecked return. A strict callback passed through validator() or another checked helper can still encounter TS7022/TS7024; annotate its result as ValidationResult in that case. Do not depend on changing a ternary into an if to control inference: implicit fallthrough and explicit undefined have the same runtime outcome.

Design a custom helper that permits unannotated consumers

If you own the helper and deliberately accept the same tradeoff as the library, declare its parameterless callback parameter as () => any, and keep the helper's own return type precise. This removes contextual return checking for the callback while preserving the type of the returned validator. Merely wrapping the callback in validator() inside the helper does not change the helper's public signature. () => unknown is not an equivalent fix for this inference cycle.

The following checked example compares both strict annotations and the permissive helper signature:

self-reference-inference.ts
import { computed } from '@angular/core';
import { field, form, group, required, validator, type Validator, type ValidationResult } from '@ngblocks/form-nodes';

// A reusable helper that checks the condition's return type.
function requiredWhen(condition: () => boolean): Validator<unknown> {
return required({ when: condition });
}

export class ExplicitComputedResult {
form = form({
other: field<number>(23),
subType: field<string>(null, [requiredWhen(() => this.isTypeVisible())]),
});

isTypeVisible = computed((): boolean => (this.form.other() ?? 0) > 30);
}

export class ExplicitConditionResult {
form = form({
other: field<number>(23),
subType: field<string>(null, [requiredWhen((): boolean => this.isTypeVisible())]),
});

isTypeVisible = computed(() => (this.form.other() ?? 0) > 30);
}

// An intentional helper-author tradeoff: callers must still return a boolean.
function inferredRequiredWhen(condition: () => any): Validator<unknown> {
return required({ when: condition });
}

export class UnannotatedConsumer {
form = form({
other: field<number>(23),
subType: field<string>(null, [inferredRequiredWhen(() => this.isTypeVisible())]),
});

isTypeVisible = computed(() => (this.form.other() ?? 0) > 30);
}

// The relaxed helper condition must not erase the form's inferred field types.
const model = new UnannotatedConsumer();
const visible: boolean = model.isTypeVisible();
const subType: string | null = model.form.subType();
// @ts-expect-error the field still rejects invalid writes
model.form.subType.set(123);
void [visible, subType];

// A localized escape hatch when the helper's signature cannot be changed.
export class UncheckedCallbackResult {
form = form({
other: field<number>(23),
subType: field<string>(null, [requiredWhen((): any => this.isTypeVisible())]),
});

isTypeVisible = computed(() => (this.form.other() ?? 0) > 30);
}

// Direct declaration callbacks support self-reference; a checked helper uses an explicit result.
export const dateRange = group({
endDate: field<string>(null),
startDate: field<string>(null, validator(({ value }): ValidationResult => {
const endDate = dateRange.endDate();
return endDate && !value()
? { kind: 'missingStartDate', message: 'Enter a start date.' }
: null;
})),
});

const startDate: string | null = dateRange.startDate();
// @ts-expect-error The return annotation does not weaken the field's value type.
dateRange.startDate.set(123);
void startDate;

If you cannot change a helper, annotating just the callback as (): any is another localized escape hatch, shown in the final class. Prefer (): boolean when the result is known: it breaks the cycle while retaining return checking. An as any cast on the whole form would lose much more.

Here, any describes the callback's accepted return type; it is not a value to return. Document the expected runtime result and the lost checking when publishing such a helper. A condition returning a string can now compile, so the convenience has a real cost. Do not widen checked helper callbacks or unrelated options unless their own use case requires a separately tested change.

Distinguish inference errors from early execution

An error such as this.isTypeVisible is not a function at runtime has a different cause: something called the condition before the class finished initializing. A type annotation cannot delay execution. Pass the callback to the validation pipeline rather than invoking it while constructing a helper, and avoid reading validation state or explicitly starting validation from unfinished initializers. Automatic startup for async validators with when is deferred, but explicit reads can start it sooner.

Finally, required does not narrow a nullable field's TypeScript value type. Handle null explicitly in comparisons, as the examples do with ?? 0, or use field.strict() when null is not allowed.