Skip to main content

asyncValidator()

asyncValidator() marks a Promise- or Observable-based validator so its owning node can manage reactive dependencies, debounce, cancellation, pending state, errors, and stale results.

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

const usernameAvailable = asyncValidator(({ value, abortSignal }) => {
return checkUsername(value(), abortSignal).then(available =>
available ? null : { kind: 'usernameTaken' },
);
});

const myForm = form({
username: field('', [usernameAvailable]),
});

Async validators must be direct entries in a node's validator source. Do not return one from a synchronous conditional validator.

🧭 API map​

I want to…Start withDetails
Let Form Nodes discover dependenciesasyncValidator(validate, options?)Callback signature
Name dependencies explicitlyasyncValidator({ params, validate, ... })Parameterized signature
Delay, condition, or recover validationdebounce, when, onErrorOption reference
Read value, tree, state, or cancellationCallback contextContext reference
Return a Promise, Observable-like value, or validation resultAsyncValidationResultReturn value
Understand pending, ordering, and stale workNode validation stateExecution lifecycle

πŸ“ Signatures​

asyncValidator(validate, options?);
asyncValidator({ params, validate, debounce?, when?, onError? });

Both signatures produce an AsyncValidator<TValue> accepted by a field, form, group, or array validator source. They differ only in how reactive dependencies are selected.

SignatureTracked dependenciesBest for
CallbackSignals read by validate and whenA concise validator with obvious dependencies
ParameterizedSignals read by params and when; validate is untrackedA stable, explicit request snapshot

Parameterless callbacks in the callback signature can reference their class form without return annotations. Their accepted return type is intentionally unchecked, but they must still return Promise-like or Observable-like validation results. Context-taking callbacks and the parameterized configuration keep their checked return contracts. Explicitly annotated standalone callback contexts still infer the value type. See Self-referencing validators.

β—† Value type and inference​

An asyncValidator() declared separately has no consuming node from which TypeScript can infer TValue. Without an explicit generic, value() is unknown; narrow it before use:

const usernameAvailable = asyncValidator(({ value }) => {
const username = value(); // unknown

return typeof username === 'string'
? checkUsername(username)
: Promise.resolve(null);
});

Specify the exact value type when the validator is intended for a known node type:

const usernameAvailable = asyncValidator<string | null>(({ value }) => {
const username = value(); // string | null

return username ? checkUsername(username) : Promise.resolve(null);
});

The same rule applies to the parameterized signature: its first generic is the validated value, and params provides inference for TParams from the snapshot it returns.

The optional TApi generic specializes the remaining parent and path properties. It does not add an api member to the context. TField specializes the node and its API; omit helper generics for inline inference. Without an exact node type, use ctx.value() for the typed value.

β—† Callback signature​

asyncValidator<TValue, TApi = AsyncValidatorApi<TValue>, TField extends AnyNode = AnyNode>(
validate: (context: AsyncValidatorContext<TValue, TApi, ValidatorOwner<TField>>) => AsyncValidationResult,
options?: AsyncValidatorOptions<TValue, TApi, ValidatorOwner<TField>>,
): AsyncValidator<TValue, TField>;

Signals read while validate runs become dependencies. A later change cancels the previous execution and schedules validation again.

const tenantId = signal('public');

const usernameAvailable = asyncValidator(({ value, abortSignal }) => {
return api.checkUsername({
username: value(),
tenantId: tenantId(),
}, abortSignal).then(available =>
available ? null : { kind: 'usernameTaken' },
);
});

β—† Parameterized signature​

asyncValidator<TValue, TParams, TApi = AsyncValidatorApi<TValue>, TField extends AnyNode = AnyNode>({
params,
validate,
debounce?,
when?,
onError?,
}): AsyncValidator<TValue, TField>;

params selects tracked inputs and validate receives the stable result. Signals read only inside validate do not become dependencies.

const usernameAvailable = asyncValidator({
params: ({ value }) => ({
username: value(),
tenantId: tenantId(),
}),
validate: ({ params, abortSignal }) => {
return api.checkUsername(params, abortSignal).then(available =>
available ? null : { kind: 'usernameTaken' },
);
},
});

βš™οΈ Options​

OptionAccepted valueAvailable inPurpose
debouncenumberBothDelays each execution
whenreactive boolean callbackBothEnables validation conditionally
onErrorerror-mapping callbackBothConverts operation failures
paramsreactive snapshot callbackParameterizedSelects and names dependencies
validateasync callbackParameterizedValidates one params snapshot

βš™οΈ Option reference​

β—† Shared options​

– debounce​

Signature: debounce?: number

Delays execution by this many milliseconds. A new trigger cancels and restarts the complete delay. The node reports pending() === true during both debounce and execution.

asyncValidator(
({ value, abortSignal }) => checkUsername(value(), abortSignal),
{ debounce: 300 },
);

The value must be a number of milliseconds. This option delays validation after a committed value or another tracked dependency changes.

– when​

Signature: when?: (context: AsyncValidatorBaseContext<TValue, TApi>) => boolean

Reactively controls whether the validator is active. A false result cancels pending work and clears this validator's current result. When it becomes true, validation is scheduled again.

asyncValidator(({ value, abortSignal }) => checkUsername(value(), abortSignal), {
when: ({ value }) => (value()?.length ?? 0) >= 3,
});

The parameterized signature does not evaluate params while when is false.

– onError​

Signature: onError?: (error: unknown, context: AsyncValidatorBaseContext<TValue, TApi>) => ValidationResult

Maps a rejected Promise, thrown error, or Observable error to an ordinary validation result.

asyncValidator(({ value }) => checkUsername(value()), {
onError: () => ({
kind: 'usernameCheckUnavailable',
message: 'The username could not be checked. Try again later.',
}),
});

Without onError, an operation failure contributes no validation error. Stale or destroyed executions do not publish a mapped result.

β—† Explicit parameters​

– params​

Signature: params: (context: AsyncValidatorBaseContext<TValue, TApi>) => TParams

Reactively derives the snapshot passed to validate. Successive results are compared before work restarts.

const location = signal({ city: 'Zurich', country: 'Switzerland' });

asyncValidator({
params: ({ value }) => ({
storeName: value(),
city: location().city,
}),
validate: ({ params, abortSignal }) => api.checkStore(params, abortSignal),
});

Primitives use Object.is(). Objects and arrays are compared one level deep:

  • a new object with equal first-level values does not restart validation;
  • changing, adding, or removing a first-level value restarts it;
  • nested objects and arrays are compared by reference, not recursively.

Include only values that should restart the request.

The signals read by params determine when Form Nodes reevaluates the snapshot. Angular signals track the signal read, not an individual property of an object stored in that signal. The shallow comparison then determines whether validation actually restarts:

const account = signal({ tenantId: 'public', theme: 'light' });

const usernameAvailable = asyncValidator({
params: ({ value }) => ({
username: value(),
tenantId: account().tenantId,
}),
validate: ({ params, abortSignal }) => {
return api.checkUsername(params, abortSignal);
},
});

account.set({ tenantId: 'public', theme: 'dark' });
// `params` is reevaluated, but validation does not restart.

account.set({ tenantId: 'private', theme: 'dark' });
// `tenantId` changed, so stale work is cancelled and validation restarts.

Returning a fresh object is therefore safe: allocation alone does not trigger another request.

– validate​

Signature: validate: (context: ParameterizedAsyncValidatorContext<TValue, TParams, TApi>) => AsyncValidationResult

Validates one stable params snapshot. It runs untracked, so put every reactive request input in params.

asyncValidator({
params: ({ value }) => ({ username: value() }),
validate: ({ params, abortSignal }) => {
return api.isUsernameAvailable(params.username, abortSignal).then(available =>
available ? null : { kind: 'usernameTaken' },
);
},
});

πŸ“– Context reference​

Every callback receives state for the node being validated. abortSignal is added to validation executions, and params only to parameterized validate.

MemberTypeAvailable in
valueSignal<TValue>All callbacks
nodeSignal<ValidatorNodeView<TField>>All callbacks
fieldSignal<ValidatorNodeView<TField>>All callbacks
rootroot-node signalAll callbacks
parentparent-node signalAll callbacks
pathpath signalAll callbacks
abortSignalAbortSignalvalidate
paramsTParamsParameterized validate

β—† Node and navigation​

– value​

Signature: value: Signal<TValue>

The current committed value. Call it as value(). Reading it in the callback signature creates a dependency; reading it in params contributes to the derived snapshot.

asyncValidator(({ value }) => checkUsername(value()));

Validation outputs, constraint metadata, and mutations are omitted from every returned node, including $api, descendants, navigation, and parent<TParent>(). This applies to when, params, validate, and onError. See the validation context boundary. ValidatorNodeView is an internal helper name, not a package import.

– 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 value and child types through a recursive read-only validation view.

context.field() returns the node. Read its committed value with context.value(), which preserves the inferred value type. Use context.field().value() when accessing it 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.

asyncValidator(({ field }) => auditNode(field()));

– 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.

– 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.

asyncValidator(({ parent }) => parent() ? validateWithParent(parent()!) : Promise.resolve(null));

– path​

Signature: path: Signal<readonly string[]>

Property names and array indexes locating the node from its root. Array indexes are strings.

asyncValidator(({ path }) => auditPath(path()));

β—† State​

– state signals​

Read 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()

Reading state in when, the callback form, or params makes it a dependency. Parameterized validate and onError can read the same state without tracking new dependencies.

β—† Execution-only members​

– abortSignal​

Signature: abortSignal: AbortSignal

Belongs to one execution and aborts when it becomes stale, inactive, or destroyed. Pass it to cancellable APIs:

asyncValidator(({ value, abortSignal }) => {
return fetch(`/api/users/${encodeURIComponent(value())}`, { signal: abortSignal })
.then(response => response.json())
.then(result => result.available ? null : { kind: 'usernameTaken' });
});

Form Nodes discards stale results even when the underlying API ignores this signal.

– params​

Signature: params: TParams

The stable snapshot for this parameterized execution. It is absent from the callback signature, when, and onError.

asyncValidator({
params: ({ value }) => ({ username: value() }),
validate: ({ params }) => checkUsername(params.username),
});

πŸ“ Return value​

Resolved results and onError results use the same defensive normalization as synchronous validators: keep errors with a readable string kind, ignore malformed entries, and warn only in development. Ignored results contribute no errors; pending state still finishes normally. Strings become { kind: 'custom', message }, including empty strings. Arrays can mix strings and error objects. See Returning messages. This filtering does not invoke onError for malformed results; that callback retains its existing exception/rejection handling. See Malformed validator results.

type AsyncValidationResult =
| PromiseLike<ValidationResult>
| ObservableLike<ValidationResult>;
Resolved or emitted resultEffect
null, undefined, or voidValidation succeeds
stringAdds an error with kind: 'custom' and the returned message, including ''
{ kind, message?, ... }Adds one error
An array of strings and/or error objectsAdds several errors in returned order

Observable-like values use their first emission and are then unsubscribed. RxJS Observables satisfy the structural contract, but Form Nodes does not require RxJS.

asyncValidator() returns the marked validator functionβ€”not a node or a separate instance with properties and methods. Add it directly to a validators source.

πŸ”Œ Execution lifecycle​

For mixed synchronous and asynchronous validators, initial setup is deferred until construction finishes, so guards and asynchronous callbacks can safely reference a class form being declared. Reading errors or pending state performs the initial setup immediately and schedules the asynchronous callback. Synchronous errors suppress asynchronous execution. Later reactive changes retain their scheduled revalidation behavior.

β—† Scheduling and status​

  • Synchronous validators run first and block async validators while they have errors.
  • pending() is true during debounce and execution.
  • Pending without an existing error produces validationStatus() === 'unknown'.
  • A completed error makes the node invalid while another async validator may remain pending.
  • Results remain ordered by validator declaration, not completion time.

β—† Dependencies and cancellation​

  • A dependency change cancels stale work and restarts the complete debounce.
  • Simultaneous value, params, and when changes coalesce into one latest execution.
  • A false when cancels work and clears this validator's result.
  • Disabled, readonly, or hidden state cancels work; returning to interactive state restarts it.
  • Destroying the lifecycle owner cancels delay and work.
  • Stale Promise resolutions, Observable emissions, and mapped errors are ignored.

πŸ“ Public types​

TypePurpose
AsyncValidator<TValue>Marked validator accepted by validator sources
AsyncValidatorOptions<TValue, TApi>Callback-signature options
ParameterizedAsyncValidatorConfig<TValue, TParams, TApi>Complete parameterized configuration
ParameterizedAsyncValidatorOptions<TValue, TParams, TApi>Parameterized marker options
AsyncValidatorBaseContext<TValue, TApi>Context shared by params, when, and onError
AsyncValidatorContext<TValue, TApi>Callback context with abortSignal
ParameterizedAsyncValidatorContext<TValue, TParams, TApi>Context with abortSignal and params
AsyncValidationResultPromise-like or Observable-like validation operation
ValidationResultSuccess, a message or error object, or an array of both

See Async validation for task-oriented examples and Advanced behavior for additional scheduling and lifecycle semantics.

Separately declared asyncValidator<TValue>() helpers preserve TValue on the node returned by context.node() and context.field(), including callable value reads and the node's value signal. This also applies to when, params, and onError contexts. See the reusable validator example.

Self-referencing conditions​

A parameterless when can reference its declaring form through a later computed without type annotations. Its return is unchecked; return a boolean. Context-taking conditions retain their typed context and boolean result. See self-referencing conditions.

Initial automatic evaluation of a configured when is deferred past synchronous construction. Validation-state reads or explicit validation can start it earlier. Disabling the condition cancels work and releases its dependency tracking; reenabling starts a fresh execution with current values.

For custom helper signatures and explicit return-type alternatives, see troubleshooting circular type inference.