Skip to content

Commit 0099218

Browse files
committed
feat(forms): add validateAsync overloads for simple function and resource options
Add validateAsync overloads for simple function with optional config and full resource options.
1 parent 96b8042 commit 0099218

6 files changed

Lines changed: 349 additions & 12 deletions

File tree

adev/src/content/guide/forms/signals/async-operations.md

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ TIP: See the [httpResource API documentation](api/common/http/httpResource) for
244244

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

247-
`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.
247+
`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.
248248

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

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

256-
### Creating a custom validation rule
256+
### Using a simple async validator
257257

258-
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:
258+
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`):
259+
260+
```ts
261+
import {Component, inject, signal} from '@angular/core';
262+
import {FieldContext, form, FormField, validateAsync} from '@angular/forms/signals';
263+
import {UsernameValidator} from './username-validator';
264+
265+
@Component({
266+
selector: 'app-registration',
267+
imports: [FormField],
268+
template: `...`,
269+
})
270+
export class Registration {
271+
registrationModel = signal({username: ''});
272+
private usernameValidator = inject(UsernameValidator);
273+
274+
registrationForm = form(this.registrationModel, (schemaPath) => {
275+
validateAsync(schemaPath.username, (ctx) => this.validateUsername(ctx), {
276+
debounce: 300,
277+
when: ({value}) => value().length >= 3,
278+
onError: (error) => ({
279+
kind: 'serverError',
280+
message: 'Could not verify username',
281+
}),
282+
});
283+
});
284+
285+
private async validateUsername({value}: FieldContext<string>) {
286+
const result = await this.usernameValidator.checkAvailability(value());
287+
288+
return result?.available ? null : {kind: 'usernameTaken', message: 'Username taken'};
289+
}
290+
}
291+
```
292+
293+
#### Optional Configuration Options
294+
295+
| **Config Option** | **Type** | **Description** |
296+
| ----------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
297+
| `debounce` | `number \| DebounceTimer` | Duration in milliseconds to wait before triggering the async operation, or a function returning a timing promise. |
298+
| `when` | `LogicFn` | A function that receives the field context and returns `true` if the async validation should run. |
299+
| `onError` | `Function` | A handler for errors thrown by the validator. Defaults to an `{ kind: 'asyncError', message: ... }` object if omitted. |
300+
301+
### Using Resouce-based validator
302+
303+
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:
259304

260305
```ts
261306
import {Component, inject, signal, resource, Signal} from '@angular/core';

adev/src/content/guide/forms/signals/validation.md

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,61 @@ NOTE: Child fields also have a `key` signal, and array item fields have both `ke
492492

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

495+
### Using validateAsync()
496+
497+
The `validateAsync()` function creates custom asynchronous validation rules. It receives a validator function that accesses the field context and returns a `Promise` resolving to:
498+
499+
| Return Value | Meaning |
500+
| --------------------- | ---------------- |
501+
| Error object | Value is invalid |
502+
| `null` or `undefined` | Value is valid |
503+
504+
```angular-ts
505+
import {Component, inject, signal} from '@angular/core';
506+
import {FieldContext, form, FormField, validateAsync} from '@angular/forms/signals';
507+
import {UserService} from './user.service';
508+
509+
@Component({
510+
selector: 'app-username-form',
511+
imports: [FormField],
512+
template: `
513+
<form novalidate>
514+
<label>
515+
Username
516+
<input [formField]="usernameForm.username" />
517+
</label>
518+
</form>
519+
`,
520+
})
521+
export class UsernameFormComponent {
522+
private userService = inject(UserService);
523+
524+
usernameModel = signal({username: ''});
525+
526+
usernameForm = form(this.usernameModel, (schemaPath) => {
527+
validateAsync(schemaPath.username, (ctx) => this.validateUsername(ctx));
528+
});
529+
530+
private async validateUsername({value}: FieldContext<string>) {
531+
const username = value();
532+
const available = await this.userService.checkUsernameAvailability(username);
533+
534+
if (!available) {
535+
return {
536+
kind: 'usernameTaken',
537+
message: 'Username is already taken',
538+
};
539+
}
540+
541+
return null;
542+
}
543+
}
544+
```
545+
546+
The validator function receives the same `FieldContext` object as synchronous `validate()`.
547+
548+
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).
549+
495550
### Using validateTree()
496551

497552
The `validateTree()` function creates custom validation rules that can target multiple fields or provide complex validation logic for a whole subtree.
@@ -505,9 +560,7 @@ interface User {
505560
lastName: string;
506561
}
507562
508-
@Component({
509-
/* ... */
510-
})
563+
@Component({/* ... */})
511564
export class UserFormComponent {
512565
readonly userModel = model<User>({
513566
firstName: '',
@@ -750,9 +803,7 @@ import {Component, computed, signal} from '@angular/core';
750803
import {form, FormField, validateStandardSchema} from '@angular/forms/signals';
751804
import z from 'zod';
752805
753-
@Component({
754-
/* ... */
755-
})
806+
@Component({/* ... */})
756807
export class DynamicSchema {
757808
model = signal({document: '', type: 'dni'});
758809

packages/forms/signals/src/api/rules/validation/validate_async.ts

Lines changed: 160 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,22 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {DebounceTimer, Resource, Signal, computed, debounced, ɵchain} from '@angular/core';
9+
import {
10+
DebounceTimer,
11+
Resource,
12+
resource,
13+
Signal,
14+
computed,
15+
debounced,
16+
ɵchain,
17+
} from '@angular/core';
1018
import {FieldNode} from '../../../field/node';
1119
import {addDefaultField} from '../../../field/validation';
1220
import {FieldPathNode} from '../../../schema/path_node';
1321
import {assertPathIsCurrent} from '../../../schema/schema';
1422
import {
1523
FieldContext,
24+
FieldValidatorAsync,
1625
LogicFn,
1726
PathKind,
1827
SchemaPath,
@@ -117,19 +126,167 @@ export interface AsyncValidatorOptions<
117126
* @param opts The async validation options.
118127
* @template TValue The type of value stored in the field being validated.
119128
* @template TParams The type of parameters to the resource.
120-
* @template TResult The type of result returned by the resource
121-
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
129+
* @template TResult The type of result returned by the resource.
130+
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array).
122131
*
123132
* @see [Signal Form Async Validation](guide/forms/signals/validation#async-validation)
124133
* @see [Custom async validation](guide/forms/signals/async-operations#custom-async-validation-with-validateasync)
134+
* @see [Custom validation rules](guide/forms/signals/validation#using-validateasync)
125135
* @category validation
126136
* @publicApi 22.0
127137
*/
128138
export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKind = PathKind.Root>(
129139
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
130140
opts: AsyncValidatorOptions<TValue, TParams, TResult, TPathKind>,
141+
): void;
142+
143+
/**
144+
* Adds async validation to the field corresponding to the given path using a simple validator function.
145+
*
146+
* @param path A path indicating the field to bind the async validation logic to.
147+
* @param logic A validator function that returns the validation errors asynchronously.
148+
* @param config Optional, allows providing any of the following options for the async validation over the simple validator function:
149+
* - `debounce`: Duration in milliseconds to wait before triggering the async operation, or a function that
150+
* returns a promise that resolves when the update should proceed.
151+
* - `when`: A function that receives the field context and returns true if the async validation should be run.
152+
* - `onError`: A function to handle errors thrown by the async validator (HTTP errors, network errors, etc.).
153+
* Receives the error and the field context, returns a list of validation errors.
154+
* @template TValue The type of value stored in the field being validated.
155+
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array).
156+
*
157+
* @see [Signal Form Async Validation](guide/forms/signals/validation#async-validation)
158+
* @see [Custom async validation](guide/forms/signals/async-operations#custom-async-validation-with-validateasync)
159+
* @see [Custom validation rules](guide/forms/signals/validation#using-validateasync)
160+
* @category validation
161+
* @publicApi 22.0
162+
*/
163+
export function validateAsync<TValue, TPathKind extends PathKind = PathKind.Root>(
164+
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
165+
logic: NoInfer<FieldValidatorAsync<TValue, TPathKind>>,
166+
config?: {
167+
debounce?: DebounceTimer<FieldContext<TValue, TPathKind> | undefined>;
168+
when?: NoInfer<LogicFn<TValue, boolean, TPathKind>>;
169+
onError?: (error: unknown, ctx: FieldContext<TValue, TPathKind>) => TreeValidationResult;
170+
},
171+
): void;
172+
173+
/**
174+
* Internal implementation for registering async validation on a field via resource options
175+
* or a simple validator function.
176+
*
177+
* @param path A path indicating the field to bind the async validation logic to.
178+
* @param optsOrLogic Either an object with full resource validator options or a simple async validator function.
179+
* @param config Optional, allows providing any of the following options for the async validation over the simple validator function:
180+
* - `debounce`: Duration in milliseconds to wait before triggering the async operation, or a function that
181+
* returns a promise that resolves when the update should proceed.
182+
* - `when`: A function that receives the field context and returns true if the async validation should be run.
183+
* - `onError`: A function to handle errors thrown by the async validator (HTTP errors, network errors, etc.).
184+
* Receives the error and the field context, returns a list of validation errors.
185+
* @template TValue The type of value stored in the field being validated.
186+
* @template TParams The type of parameters to the resource.
187+
* @template TResult The type of result returned by the resource.
188+
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array).
189+
*
190+
* @internal
191+
*/
192+
export function validateAsync<
193+
TValue,
194+
TParams = unknown,
195+
TResult = unknown,
196+
TPathKind extends PathKind = PathKind.Root,
197+
>(
198+
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
199+
optsOrLogic:
200+
| AsyncValidatorOptions<TValue, TParams, TResult, TPathKind>
201+
| FieldValidatorAsync<TValue, TPathKind>,
202+
config?: {
203+
debounce?: DebounceTimer<FieldContext<TValue, TPathKind> | undefined>;
204+
when?: NoInfer<LogicFn<TValue, boolean, TPathKind>>;
205+
onError?: (error: unknown, ctx: FieldContext<TValue, TPathKind>) => TreeValidationResult;
206+
},
131207
): void {
132208
assertPathIsCurrent(path);
209+
210+
if (typeof optsOrLogic === 'function') {
211+
const logic = optsOrLogic;
212+
const configOpts = config ?? {};
213+
214+
// Check if debounce option is a function, wrap it to pass the field context and last value.
215+
const debounce =
216+
typeof configOpts.debounce === 'function'
217+
? (
218+
value: {ctx: FieldContext<TValue, TPathKind>; value: TValue} | undefined,
219+
lastValue: unknown,
220+
) => (configOpts.debounce as Function)(value?.ctx, lastValue)
221+
: configOpts.debounce;
222+
223+
// Map the simple validator function to the full AsyncValidatorOptions structure.
224+
// Reading `ctx.value()` inside `params` ensures signal dependencies are tracked
225+
// so the resource loader re-runs whenever the field value changes.
226+
const mappedOpts: AsyncValidatorOptions<
227+
TValue,
228+
{ctx: FieldContext<TValue, TPathKind>; value: TValue} | undefined,
229+
TreeValidationResult | undefined,
230+
TPathKind
231+
> = {
232+
params: (ctx: FieldContext<TValue, TPathKind>) => ({
233+
ctx,
234+
value: ctx.value(),
235+
}),
236+
debounce: debounce as DebounceTimer<
237+
{ctx: FieldContext<TValue, TPathKind>; value: TValue} | undefined
238+
>,
239+
factory: (
240+
paramsSignal: Signal<{ctx: FieldContext<TValue, TPathKind>; value: TValue} | undefined>,
241+
) =>
242+
resource({
243+
params: () => paramsSignal(),
244+
loader: async ({
245+
params,
246+
}: {
247+
params: {ctx: FieldContext<TValue, TPathKind>; value: TValue} | undefined;
248+
}) => {
249+
if (!params) return undefined;
250+
const res = await logic(params.ctx);
251+
return res as TreeValidationResult | undefined;
252+
},
253+
}),
254+
onSuccess: (result: TreeValidationResult | undefined) => result,
255+
onError:
256+
configOpts.onError ??
257+
((error: unknown) => ({
258+
kind: 'asyncError',
259+
message: String(error ?? 'Async validation failed'),
260+
})),
261+
when: configOpts.when,
262+
};
263+
264+
registerAsyncResourceValidator(path, mappedOpts);
265+
return;
266+
}
267+
268+
registerAsyncResourceValidator(path, optsOrLogic);
269+
}
270+
271+
/**
272+
* Registers an async resource validator for the given path and options.
273+
*
274+
* @template TValue The type of value stored in the field being validated.
275+
* @template TParams The type of parameters to the resource.
276+
* @template TResult The type of result returned by the resource
277+
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
278+
* @param path
279+
* @param opts
280+
*/
281+
function registerAsyncResourceValidator<
282+
TValue,
283+
TParams,
284+
TResult,
285+
TPathKind extends PathKind = PathKind.Root,
286+
>(
287+
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
288+
opts: AsyncValidatorOptions<TValue, TParams, TResult, TPathKind>,
289+
): void {
133290
const pathNode = FieldPathNode.unwrapFieldPath(path);
134291

135292
const RESOURCE = createManagedMetadataKey<ReturnType<typeof opts.factory>, TParams | undefined>(

packages/forms/signals/src/api/types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,26 @@ export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> =
900900
TPathKind
901901
>;
902902

903+
/**
904+
* A function that takes the `FieldContext` for the field being validated and returns a
905+
* `ValidationResult` or pending status indicating errors for the field asynchronously.
906+
*
907+
* @template TValue The type of value stored in the field being validated
908+
* @template TPathKind The kind of path being validated (root field, child field, or item of an array)
909+
*
910+
* @see [Custom validation rules](guide/forms/signals/validation#using-validateasync)
911+
*
912+
* @category validation
913+
* @publicApi 22.0
914+
*/
915+
export type FieldValidatorAsync<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<
916+
TValue,
917+
| ValidationResult<ValidationError.WithoutFieldTree>
918+
| Promise<ValidationResult<ValidationError.WithoutFieldTree>>
919+
| 'pending',
920+
TPathKind
921+
>;
922+
903923
/**
904924
* A function that takes the `FieldContext` for the field being validated and returns a
905925
* `TreeValidationResult` indicating errors for the field and its sub-fields.

0 commit comments

Comments
 (0)