skip to content

In Angular Signal Forms, how do you add a password-confirmation rule and an async username-availability check to a registration schema?

level: middleimportance: should knowfreq 36%

answer

  1. validate() gets a field context
  2. valueOf reads another path
  3. async waits for sync rules
  4. pending is neither valid nor invalid
  5. debounce the keystrokes

basics

~20 s

validate() on confirmPassword compares value() with valueOf(path.password) and re-runs when either changes. validateHttp() or validateAsync() on username runs only after the sync rules pass, sets pending() meanwhile, and maps the answer to an error or null.

solid answer

~40 s

Both are schema rules. For the confirmation, `validate(path.confirmPassword, ({value, valueOf}) => ...)` compares the field's `value()` with `valueOf(path.password)` and returns `null` or an error like `{kind: 'passwordMismatch', message}`; because both reads are signals, it re-runs when either password changes. For availability, `validateHttp(path.username, {request, onSuccess, onError})` (or the lower-level `validateAsync` with `params`, a resource `factory`, `onSuccess` and `onError`) runs **only after every synchronous rule on that field passes**. While it runs, `pending()` is `true`, and since `valid()` requires no errors *and* nothing pending, both `valid()` and `invalid()` are `false`. A changed value cancels the in-flight check, and `debounce(path.username, 300)` stops one request per keystroke.

code

ts · 44 lines
ts
import {Component, signal} from '@angular/core';
import {
  debounce, form, FormField, minLength, required, validate, validateHttp,
} from '@angular/forms/signals';

@Component({
  selector: 'app-registration',
  imports: [FormField],
  template: `
    <input [formField]="registrationForm.username" />
    @if (registrationForm.username().pending()) {
      <span>Checking availability...</span>
    }
    <input type="password" [formField]="registrationForm.password" />
    <input type="password" [formField]="registrationForm.confirmPassword" />
  `,
})
export class Registration {
  protected readonly model = signal({username: '', password: '', confirmPassword: ''});

  protected readonly registrationForm = form(this.model, (path) => {
    required(path.username, {message: 'Choose a username'});
    minLength(path.username, 3, {message: 'At least 3 characters'});
    debounce(path.username, 300); // hold keystrokes before writing to the model

    // async: runs only after the synchronous rules above pass
    validateHttp(path.username, {
      request: ({value}) => `/api/users/check?username=${encodeURIComponent(value())}`,
      onSuccess: (res: {available: boolean}) =>
        res.available ? null : {kind: 'usernameTaken', message: 'Username is taken'},
      onError: () => ({kind: 'serverError', message: 'Could not check the username'}),
    });

    required(path.password, {message: 'Password is required'});
    minLength(path.password, 12, {message: 'Use at least 12 characters'});

    // cross-field: re-runs when either password field changes
    validate(path.confirmPassword, ({value, valueOf}) =>
      value() === valueOf(path.password)
        ? null
        : {kind: 'passwordMismatch', message: 'Passwords do not match'},
    );
  });
}

go deeper

for a junior

Know that custom rules go in the schema with validate(), returning an error object with a kind, or null when valid.

for a middle

Explain valueOf for cross-field rules, that async rules wait for sync rules, and why valid() and invalid() can both be false while a check is pending.

for a senior

Design the async check end to end: debounce, skip conditions, onError mapping, cancellation on change, and whether pending checks block submission.

for a principal

Decide where availability-style checks belong (form, submit action or server only) given load, latency and the cost of a stale answer.

## Two kinds of custom rule The built-in rules (`required`, `email`, `minLength`, ...) cover single-field format checks. A registration form usually needs two more: - a **cross-field** rule: "confirm password must equal password"; - an **asynchronous** rule: "this username is not already taken", which needs the server. Both are written inside the schema function passed to `form()`, next to the built-in rules. ## Cross-field rules with validate() `validate(path, fn)` attaches a synchronous rule to one field. The function receives a **field context** and returns either an error object or `null`/`undefined` for valid. The context carries: | Member | Meaning | |---|---| | `value` | signal of this field's value | | `valueOf(path)` | the value of another field | | `stateOf(path)` | the state of another field | | `fieldTree` | this field | | `pathKeys` | keys from the root to this field | For the confirmation rule, attach it to `confirmPassword` and read the other field with `valueOf(path.password)`. Every read is a signal read, so the rule re-runs when **either** field changes: fixing the first password clears the mismatch without touching the second. The error lands on `confirmPassword`, where the user will look for it. An error is an object with a `kind` (your own name, such as `'passwordMismatch'`) and an optional `message`. ## Async rules with validateHttp() and validateAsync() Signal Forms offers two async functions: 1. **`validateHttp(path, {request, onSuccess, onError})`**: `request` builds a URL or request from the field context (return `undefined` to skip), `onSuccess` maps the response to an error or `null`, `onError` maps a failed request to an error. It is built on `validateAsync` with an `httpResource`. 2. **`validateAsync(path, {params, factory, onSuccess, onError})`**: the lower-level form. `params` computes the input from the field context, `factory` receives those params as a signal and returns a `Resource`, and the two mappers turn the result or failure into errors. Use it for non-HTTP checks or custom caching. Timing rules that matter in interviews: - async rules run **only after all synchronous rules on the field pass**, so a too-short username never hits the server; - while the check runs, `pending()` is `true`; - when the value changes, the in-flight check is cancelled and a new one starts. ## Why valid() is not the opposite of invalid() Signal Forms defines the two flags independently: - **`invalid()`** is `true` when there is at least one error, regardless of pending checks; - **`valid()`** is `true` only when there are no errors **and** nothing is pending. So during an availability check with no other errors, both are `false`. That matters for a "Register" button: `[disabled]="registrationForm().invalid()"` leaves it enabled mid-check, while `[disabled]="!registrationForm().valid()"` disables it until the answer arrives. ## Conditional and inactive rules Registration forms often have rules that apply only sometimes: a company name required only for business accounts, for example. Built-in rules accept a `when` option, `required(path.company, {when: ({valueOf}) => valueOf(path.accountType) === 'business'})`, and `applyWhen()` applies a whole block of rules conditionally. Separately, fields made **hidden** or **disabled** through `hidden()` or `disabled()` do not run their validation at all, and a disabled field does not contribute to its parent's validity, so a hidden "company" section cannot keep the whole form invalid. ## Avoiding one request per keystroke `debounce(path.username, 300)` holds UI changes for 300 ms before writing them to the model, so the async rule sees the value only after the user pauses. When the field becomes touched (for example on blur, or when the user clicks submit), the pending value is flushed immediately. `debounce(path, 'blur')` commits only on blur. ## Practical checklist - put the cross-field rule on the field whose message you want to show; - add `required`/`minLength` before the async rule so obviously bad values never reach the server; - map network failures in `onError` to a distinct `kind` so the UI can say "could not check" instead of "taken"; - decide whether a pending check should block submission (see `submit()`'s `ignoreValidators`).

  • Why is the username check not sent for a two-letter username in the example?
    Async rules on a field run only after all of that field's synchronous rules pass. `minLength(path.username, 3)` fails for two letters, so `validateHttp` never starts and `pending()` stays `false`; the user sees the length error instead.
  • When would you use validateAsync instead of validateHttp?
    When the check is not a plain HTTP call, or you need control over the resource: a WebSocket or IndexedDB lookup, a custom cache, or retry logic. `validateAsync` takes `params` and a `factory` that returns any `Resource`, plus `onSuccess` and `onError` mappers.

saying these in an interview costs you the question

  • Async rules run in parallel with the synchronous rules on every change
  • valid() and invalid() are always opposites
  • A cross-field rule must be attached to the form root to read another field
  • Returning an empty string from validate() marks the field valid
  • An in-flight availability check keeps running after the user changes the value