Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions adev/src/content/guide/forms/signals/async-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ TIP: See the [httpResource API documentation](api/common/http/httpResource) for

Most applications should use `validateHttp()` for async validation. It handles HTTP requests with minimal configuration and covers the majority of use cases.

`validateAsync()` is a lower-level API that exposes Angular's resource primitive directly. It offers complete control but requires more code and familiarity with Angular's resource API.
`validateAsync()` is a lower-level API that exposes Angular's resource primitive. It supports both a simple async function for standard checks and full resource configuration for complete control, which requires more code and familiarity with Angular's resource API.

Consider `validateAsync()` only when `validateHttp()` can't meet your needs. Some examples include:

Expand All @@ -253,9 +253,54 @@ Consider `validateAsync()` only when `validateHttp()` can't meet your needs. Som
- **Complex retry logic** - Custom backoff strategies or conditional retries
- **Direct resource access** - When you need the full resource lifecycle

### Creating a custom validation rule
### Using a simple async validator

The `validateAsync()` function requires four properties: `params`, `factory`, `onSuccess`, and `onError`. The `params` function returns the parameters for your resource, while `factory` creates the resource:
Pass an async validator function that receives the field context and returns validation errors or `null` or `undefined`. You can optionally supply a config object to handle debouncing, conditional execution (`when`), or error handling (`onError`):

```ts
import {Component, inject, signal} from '@angular/core';
import {FieldContext, form, FormField, validateAsync} from '@angular/forms/signals';
import {UsernameValidator} from './username-validator';

@Component({
selector: 'app-registration',
imports: [FormField],
template: `...`,
})
export class Registration {
registrationModel = signal({username: ''});
private usernameValidator = inject(UsernameValidator);

registrationForm = form(this.registrationModel, (schemaPath) => {
validateAsync(schemaPath.username, (ctx) => this.validateUsername(ctx), {
debounce: 300,
when: ({value}) => value().length >= 3,
onError: (error) => ({
kind: 'serverError',
message: 'Could not verify username',
}),
});
});

private async validateUsername({value}: FieldContext<string>) {
const result = await this.usernameValidator.checkAvailability(value());

return result?.available ? null : {kind: 'usernameTaken', message: 'Username taken'};
}
}
```

#### Optional Configuration Options

| **Config Option** | **Type** | **Description** |
| ----------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `debounce` | `number \| DebounceTimer` | Duration in milliseconds to wait before triggering the async operation, or a function returning a timing promise. |
| `when` | `LogicFn` | A function that receives the field context and returns `true` if the async validation should run. |
| `onError` | `Function` | A handler for errors thrown by the validator. Defaults to an `{ kind: 'asyncError', message: ... }` object if omitted. |

### Using Resouce-based validator

For advanced control, pass an options object containing `params`, `factory`, `onSuccess`, and `onError`. The params function returns parameters for your resource, while `factory` creates the resource:

```ts
import {Component, inject, signal, resource, Signal} from '@angular/core';
Expand Down
63 changes: 57 additions & 6 deletions adev/src/content/guide/forms/signals/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,61 @@ NOTE: Child fields also have a `key` signal, and array item fields have both `ke

Return an error object with `kind` and `message` when validation fails. Return `null` or `undefined` when validation passes.

### Using validateAsync()

The `validateAsync()` function creates custom asynchronous validation rules. It receives a validator function that accesses the field context and returns a `Promise` resolving to:

| Return Value | Meaning |
| --------------------- | ---------------- |
| Error object | Value is invalid |
| `null` or `undefined` | Value is valid |

```angular-ts
import {Component, inject, signal} from '@angular/core';
import {FieldContext, form, FormField, validateAsync} from '@angular/forms/signals';
import {UserService} from './user.service';

@Component({
selector: 'app-username-form',
imports: [FormField],
template: `
<form novalidate>
<label>
Username
<input [formField]="usernameForm.username" />
</label>
</form>
`,
})
export class UsernameFormComponent {
private userService = inject(UserService);

usernameModel = signal({username: ''});

usernameForm = form(this.usernameModel, (schemaPath) => {
validateAsync(schemaPath.username, (ctx) => this.validateUsername(ctx));
});

private async validateUsername({value}: FieldContext<string>) {
const username = value();
const available = await this.userService.checkUsernameAvailability(username);

if (!available) {
return {
kind: 'usernameTaken',
message: 'Username is already taken',
};
}

return null;
}
}
```

The validator function receives the same `FieldContext` object as synchronous `validate()`.

NOTE: For advanced async validation and complex scenarios requiring execution delays, conditional checks, unhandled error fallbacks, or direct access to Angular's `resource()` primitive, see the [Custom async validation with validateAsync() guide](guide/forms/signals/async-operations#custom-async-validation-with-validateasync).

### Using validateTree()

The `validateTree()` function creates custom validation rules that can target multiple fields or provide complex validation logic for a whole subtree.
Expand All @@ -505,9 +560,7 @@ interface User {
lastName: string;
}

@Component({
/* ... */
})
@Component({/* ... */})
export class UserFormComponent {
readonly userModel = model<User>({
firstName: '',
Expand Down Expand Up @@ -750,9 +803,7 @@ import {Component, computed, signal} from '@angular/core';
import {form, FormField, validateStandardSchema} from '@angular/forms/signals';
import z from 'zod';

@Component({
/* ... */
})
@Component({/* ... */})
export class DynamicSchema {
model = signal({document: '', type: 'dni'});

Expand Down
163 changes: 160 additions & 3 deletions packages/forms/signals/src/api/rules/validation/validate_async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,22 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {DebounceTimer, Resource, Signal, computed, debounced, ɵchain} from '@angular/core';
import {
DebounceTimer,
Resource,
resource,
Signal,
computed,
debounced,
ɵchain,
} from '@angular/core';
import {FieldNode} from '../../../field/node';
import {addDefaultField} from '../../../field/validation';
import {FieldPathNode} from '../../../schema/path_node';
import {assertPathIsCurrent} from '../../../schema/schema';
import {
FieldContext,
FieldValidatorAsync,
LogicFn,
PathKind,
SchemaPath,
Expand Down Expand Up @@ -117,19 +126,167 @@ export interface AsyncValidatorOptions<
* @param opts The async validation options.
* @template TValue The type of value stored in the field being validated.
* @template TParams The type of parameters to the resource.
* @template TResult The type of result returned by the resource
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
* @template TResult The type of result returned by the resource.
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array).
*
* @see [Signal Form Async Validation](guide/forms/signals/validation#async-validation)
* @see [Custom async validation](guide/forms/signals/async-operations#custom-async-validation-with-validateasync)
* @see [Custom validation rules](guide/forms/signals/validation#using-validateasync)
* @category validation
* @publicApi 22.0
*/
export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKind = PathKind.Root>(
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
opts: AsyncValidatorOptions<TValue, TParams, TResult, TPathKind>,
): void;

/**
* Adds async validation to the field corresponding to the given path using a simple validator function.
*
* @param path A path indicating the field to bind the async validation logic to.
* @param logic A validator function that returns the validation errors asynchronously.
* @param config Optional, allows providing any of the following options for the async validation over the simple validator function:
* - `debounce`: Duration in milliseconds to wait before triggering the async operation, or a function that
* returns a promise that resolves when the update should proceed.
* - `when`: A function that receives the field context and returns true if the async validation should be run.
* - `onError`: A function to handle errors thrown by the async validator (HTTP errors, network errors, etc.).
* Receives the error and the field context, returns a list of validation errors.
* @template TValue The type of value stored in the field being validated.
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array).
*
* @see [Signal Form Async Validation](guide/forms/signals/validation#async-validation)
* @see [Custom async validation](guide/forms/signals/async-operations#custom-async-validation-with-validateasync)
* @see [Custom validation rules](guide/forms/signals/validation#using-validateasync)
* @category validation
* @publicApi 22.0
*/
export function validateAsync<TValue, TPathKind extends PathKind = PathKind.Root>(
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
logic: NoInfer<FieldValidatorAsync<TValue, TPathKind>>,
config?: {
debounce?: DebounceTimer<FieldContext<TValue, TPathKind> | undefined>;
when?: NoInfer<LogicFn<TValue, boolean, TPathKind>>;
onError?: (error: unknown, ctx: FieldContext<TValue, TPathKind>) => TreeValidationResult;
},
): void;

/**
* Internal implementation for registering async validation on a field via resource options
* or a simple validator function.
*
* @param path A path indicating the field to bind the async validation logic to.
* @param optsOrLogic Either an object with full resource validator options or a simple async validator function.
* @param config Optional, allows providing any of the following options for the async validation over the simple validator function:
* - `debounce`: Duration in milliseconds to wait before triggering the async operation, or a function that
* returns a promise that resolves when the update should proceed.
* - `when`: A function that receives the field context and returns true if the async validation should be run.
* - `onError`: A function to handle errors thrown by the async validator (HTTP errors, network errors, etc.).
* Receives the error and the field context, returns a list of validation errors.
* @template TValue The type of value stored in the field being validated.
* @template TParams The type of parameters to the resource.
* @template TResult The type of result returned by the resource.
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array).
*
* @internal
*/
export function validateAsync<
TValue,
TParams = unknown,
TResult = unknown,
TPathKind extends PathKind = PathKind.Root,
>(
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
optsOrLogic:
| AsyncValidatorOptions<TValue, TParams, TResult, TPathKind>
| FieldValidatorAsync<TValue, TPathKind>,
config?: {
debounce?: DebounceTimer<FieldContext<TValue, TPathKind> | undefined>;
when?: NoInfer<LogicFn<TValue, boolean, TPathKind>>;
onError?: (error: unknown, ctx: FieldContext<TValue, TPathKind>) => TreeValidationResult;
},
): void {
assertPathIsCurrent(path);

if (typeof optsOrLogic === 'function') {
const logic = optsOrLogic;
const configOpts = config ?? {};

// Check if debounce option is a function, wrap it to pass the field context and last value.
const debounce =
typeof configOpts.debounce === 'function'
? (
value: {ctx: FieldContext<TValue, TPathKind>; value: TValue} | undefined,
lastValue: unknown,
) => (configOpts.debounce as Function)(value?.ctx, lastValue)
: configOpts.debounce;

// Map the simple validator function to the full AsyncValidatorOptions structure.
// Reading `ctx.value()` inside `params` ensures signal dependencies are tracked
// so the resource loader re-runs whenever the field value changes.
const mappedOpts: AsyncValidatorOptions<
TValue,
{ctx: FieldContext<TValue, TPathKind>; value: TValue} | undefined,
TreeValidationResult | undefined,
TPathKind
> = {
params: (ctx: FieldContext<TValue, TPathKind>) => ({
ctx,
value: ctx.value(),
}),
debounce: debounce as DebounceTimer<
{ctx: FieldContext<TValue, TPathKind>; value: TValue} | undefined
>,
factory: (
paramsSignal: Signal<{ctx: FieldContext<TValue, TPathKind>; value: TValue} | undefined>,
) =>
resource({
params: () => paramsSignal(),
loader: async ({
params,
}: {
params: {ctx: FieldContext<TValue, TPathKind>; value: TValue} | undefined;
}) => {
if (!params) return undefined;
const res = await logic(params.ctx);
return res as TreeValidationResult | undefined;
},
}),
onSuccess: (result: TreeValidationResult | undefined) => result,
onError:
configOpts.onError ??
((error: unknown) => ({
kind: 'asyncError',
message: String(error ?? 'Async validation failed'),
})),
when: configOpts.when,
};

registerAsyncResourceValidator(path, mappedOpts);
return;
}

registerAsyncResourceValidator(path, optsOrLogic);
}

/**
* Registers an async resource validator for the given path and options.
*
* @template TValue The type of value stored in the field being validated.
* @template TParams The type of parameters to the resource.
* @template TResult The type of result returned by the resource
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
* @param path
* @param opts
*/
function registerAsyncResourceValidator<
TValue,
TParams,
TResult,
TPathKind extends PathKind = PathKind.Root,
>(
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
opts: AsyncValidatorOptions<TValue, TParams, TResult, TPathKind>,
): void {
const pathNode = FieldPathNode.unwrapFieldPath(path);

const RESOURCE = createManagedMetadataKey<ReturnType<typeof opts.factory>, TParams | undefined>(
Expand Down
20 changes: 20 additions & 0 deletions packages/forms/signals/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,26 @@ export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> =
TPathKind
>;

/**
* A function that takes the `FieldContext` for the field being validated and returns a
* `ValidationResult` or pending status indicating errors for the field asynchronously.
*
* @template TValue The type of value stored in the field being validated
* @template TPathKind The kind of path being validated (root field, child field, or item of an array)
*
* @see [Custom validation rules](guide/forms/signals/validation#using-validateasync)
*
* @category validation
* @publicApi 22.0
*/
export type FieldValidatorAsync<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<
TValue,
| ValidationResult<ValidationError.WithoutFieldTree>
| Promise<ValidationResult<ValidationError.WithoutFieldTree>>
| 'pending',
TPathKind
>;

/**
* A function that takes the `FieldContext` for the field being validated and returns a
* `TreeValidationResult` indicating errors for the field and its sub-fields.
Expand Down
Loading