Forms need validation to ensure users provide correct, complete data before submission. Without validation, you would need to handle data quality issues on the server, provide poor user experience with unclear error messages, and manually check every constraint.
Signal Forms provides a schema-based validation approach. Validation rules bind to fields using a schema function, run automatically when values change, and expose errors through field state signals. This enables reactive validation that updates as users interact with the form.
Validation basics
Validation in Signal Forms is defined through a schema function passed as the second argument to form().
The schema function
The schema function receives a SchemaPathTree object that lets you define your validation rules:
app.ts
import {Component, signal, ChangeDetectionStrategy} from '@angular/core';import {form, Field, required, email, submit} from '@angular/forms/signals';interface LoginData { email: string; password: string;}@Component({ selector: 'app-root', templateUrl: 'app.html', styleUrl: 'app.css', imports: [Field], changeDetection: ChangeDetectionStrategy.OnPush,})export class App { loginModel = signal<LoginData>({ email: '', password: '', }); loginForm = form(this.loginModel, (schemaPath) => { required(schemaPath.email, {message: 'Email is required'}); email(schemaPath.email, {message: 'Enter a valid email address'}); required(schemaPath.password, {message: 'Password is required'}); }); onSubmit(event: Event) { event.preventDefault(); submit(this.loginForm, async () => { const credentials = this.loginModel(); // In a real app, this would be async: // await this.authService.login(credentials); console.log('Logging in with:', credentials); }); }}
The schema function runs once during form initialization. Validation rules bind to fields using the schema path parameter (such as schemaPath.email, schemaPath.password), and validation runs automatically whenever field values change.
NOTE: The schema callback parameter (schemaPath in these examples) is a SchemaPathTree object that provides paths to all fields in your form. You can name this parameter anything you like.
How validation works
Validation in Signal Forms follows this pattern:
- Define validation rules in schema - Bind validation rules to fields in the schema function
- Automatic execution - Validation rules run when field values change
- Error propagation - Validation errors are exposed through field state signals
- Reactive updates - UI automatically updates when validation state changes
Validation runs on every value change for interactive fields. Hidden and disabled fields don't run validation - their validation rules are skipped until the field becomes interactive again.
Validation timing
Validation rules execute in this order:
- Synchronous validation - All synchronous validation rules run when value changes
- Asynchronous validation - Asynchronous validation rules run only after all synchronous validation rules pass
- Field state updates - The
valid(),invalid(),errors(), andpending()signals update
Synchronous validation rules (like required(), email()) complete immediately. Asynchronous validation rules (like validateHttp()) may take time and set the pending() signal to true while executing.
All validation rules run on every change - validation doesn't short-circuit after the first error. If a field has both required() and email() validation rules, both run, and both can produce errors simultaneously.
Built-in validation rules
Signal Forms provides validation rules for common validation scenarios. All built-in validation rules accept an options object for custom error messages and conditional logic.
required()
The required() validation rule ensures a field has a value:
import { Component, signal } from '@angular/core'import { form, Field, required } from '@angular/forms/signals'@Component({ selector: 'app-registration', imports: [Field], template: ` <form> <label> Username <input [field]="registrationForm.username" /> </label> <label> Email <input type="email" [field]="registrationForm.email" /> </label> <button type="submit">Register</button> </form> `})export class RegistrationComponent { registrationModel = signal({ username: '', email: '' }) registrationForm = form(this.registrationModel, (schemaPath) => { required(schemaPath.username, { message: 'Username is required' }) required(schemaPath.email, { message: 'Email is required' }) })}
A field is considered "empty" when:
| Condition | Example |
|---|---|
Value is null |
null, |
| Value is an empty string | '' |
| Value is an empty array | [] |
For conditional requirements, use the when option:
registrationForm = form(this.registrationModel, (schemaPath) => { required(schemaPath.promoCode, { message: 'Promo code is required for discounts', when: ({valueOf}) => valueOf(schemaPath.applyDiscount) })})
The validation rule only runs when the when function returns true.
email()
The email() validation rule checks for valid email format:
import { Component, signal } from '@angular/core'import { form, Field, email } from '@angular/forms/signals'@Component({ selector: 'app-contact', imports: [Field], template: ` <form> <label> Your Email <input type="email" [field]="contactForm.email" /> </label> </form> `})export class ContactComponent { contactModel = signal({ email: '' }) contactForm = form(this.contactModel, (schemaPath) => { email(schemaPath.email, { message: 'Please enter a valid email address' }) })}
The email() validation rule uses a standard email format regex. It accepts addresses like user@example.com but rejects malformed addresses like user@ or @example.com.
min() and max()
The min() and max() validation rules work with numeric values:
import { Component, signal } from '@angular/core'import { form, Field, min, max } from '@angular/forms/signals'@Component({ selector: 'app-age-form', imports: [Field], template: ` <form> <label> Age <input type="number" [field]="ageForm.age" /> </label> <label> Rating (1-5) <input type="number" [field]="ageForm.rating" /> </label> </form> `})export class AgeFormComponent { ageModel = signal({ age: 0, rating: 0 }) ageForm = form(this.ageModel, (schemaPath) => { min(schemaPath.age, 18, { message: 'You must be at least 18 years old' }) max(schemaPath.age, 120, { message: 'Please enter a valid age' }) min(schemaPath.rating, 1, { message: 'Rating must be at least 1' }) max(schemaPath.rating, 5, { message: 'Rating cannot exceed 5' }) })}
You can use computed values for dynamic constraints:
ageForm = form(this.ageModel, (schemaPath) => { min(schemaPath.participants, () => this.minimumRequired(), { message: 'Not enough participants' })})
minLength() and maxLength()
The minLength() and maxLength() validation rules work with strings and arrays:
import { Component, signal } from '@angular/core'import { form, Field, minLength, maxLength } from '@angular/forms/signals'@Component({ selector: 'app-password-form', imports: [Field], template: ` <form> <label> Password <input type="password" [field]="passwordForm.password" /> </label> <label> Bio <textarea [field]="passwordForm.bio"></textarea> </label> </form> `})export class PasswordFormComponent { passwordModel = signal({ password: '', bio: '' }) passwordForm = form(this.passwordModel, (schemaPath) => { minLength(schemaPath.password, 8, { message: 'Password must be at least 8 characters' }) maxLength(schemaPath.password, 100, { message: 'Password is too long' }) maxLength(schemaPath.bio, 500, { message: 'Bio cannot exceed 500 characters' }) })}
For strings, "length" means the number of characters. For arrays, "length" means the number of elements.
pattern()
The pattern() validation rule validates against a regular expression:
import { Component, signal } from '@angular/core'import { form, Field, pattern } from '@angular/forms/signals'@Component({ selector: 'app-phone-form', imports: [Field], template: ` <form> <label> Phone Number <input [field]="phoneForm.phone" placeholder="555-123-4567" /> </label> <label> Postal Code <input [field]="phoneForm.postalCode" placeholder="12345" /> </label> </form> `})export class PhoneFormComponent { phoneModel = signal({ phone: '', postalCode: '' }) phoneForm = form(this.phoneModel, (schemaPath) => { pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, { message: 'Phone must be in format: 555-123-4567' }) pattern(schemaPath.postalCode, /^\d{5}$/, { message: 'Postal code must be 5 digits' }) })}
Common patterns:
| Pattern Type | Regular Expression | Example |
|---|---|---|
| Phone | /^\d{3}-\d{3}-\d{4}$/ |
555-123-4567 |
| Postal code (US) | /^\d{5}$/ |
12345 |
| Alphanumeric | /^[a-zA-Z0-9]+$/ |
abc123 |
| URL-safe | /^[a-zA-Z0-9_-]+$/ |
my-url_123 |
Validation errors
When validation rules fail, they produce error objects that describe what went wrong. Understanding error structure helps you provide clear feedback to users.
Error structure
Each validation error object contains these properties:
| Property | Description |
|---|---|
kind |
The validation rule that failed (e.g., "required", "email", "minLength") |
message |
Optional human-readable error message |
Built-in validation rules automatically set the kind property. The message property is optional - you can provide custom messages through validation rule options.
Custom error messages
All built-in validation rules accept a message option for custom error text:
import { Component, signal } from '@angular/core'import { form, Field, required, minLength } from '@angular/forms/signals'@Component({ selector: 'app-signup', imports: [Field], template: ` <form> <label> Username <input [field]="signupForm.username" /> </label> <label> Password <input type="password" [field]="signupForm.password" /> </label> </form> `})export class SignupComponent { signupModel = signal({ username: '', password: '' }) signupForm = form(this.signupModel, (schemaPath) => { required(schemaPath.username, { message: 'Please choose a username' }) required(schemaPath.password, { message: 'Password cannot be empty' }) minLength(schemaPath.password, 12, { message: 'Password must be at least 12 characters for security' }) })}
Custom messages should be clear, specific, and tell users how to fix the problem. Instead of "Invalid input", use "Password must be at least 12 characters for security".
Multiple errors per field
When a field has multiple validation rules, each validation rule runs independently and can produce an error:
signupForm = form(this.signupModel, (schemaPath) => { required(schemaPath.email, { message: 'Email is required' }) email(schemaPath.email, { message: 'Enter a valid email address' }) minLength(schemaPath.email, 5, { message: 'Email is too short' })})
If the email field is empty, only the required() error appears. If the user types "a@b", both email() and minLength() errors appear. All validation rules run - validation doesn't stop after the first failure.
TIP: Use the touched() && invalid() pattern in your templates to prevent errors from appearing before users have interacted with a field. For comprehensive guidance on displaying validation errors, see the Field State Management guide.
Custom validation rules
While built-in validation rules handle common cases, you'll often need custom validation logic for business rules, complex formats, or domain-specific constraints.
Using validate()
The validate() function creates custom validation rules. It receives a validator function that accesses the field context and returns:
| Return Value | Meaning |
|---|---|
| Error object | Value is invalid |
null or undefined |
Value is valid |
import { Component, signal } from '@angular/core'import { form, Field, validate } from '@angular/forms/signals'@Component({ selector: 'app-url-form', imports: [Field], template: ` <form> <label> Website URL <input [field]="urlForm.website" /> </label> </form> `})export class UrlFormComponent { urlModel = signal({ website: '' }) urlForm = form(this.urlModel, (schemaPath) => { validate(schemaPath.website, ({value}) => { if (!value().startsWith('https://')) { return { kind: 'https', message: 'URL must start with https://' } } return null }) })}
The validator function receives a FieldContext object with:
| Property | Type | Description |
|---|---|---|
value |
Signal | Signal containing the current field value |
state |
FieldState | The field state reference |
field |
FieldTree | The field tree reference |
valueOf() |
Method | Get the value of another field by path |
stateOf() |
Method | Get the state of another field by path |
fieldTreeOf() |
Method | Get the field tree of another field by path |
pathKeys |
Signal | Path keys from root to current field |
NOTE: Child fields also have a key signal, and array item fields have both key and index signals.
Return an error object with kind and message when validation fails. Return null or undefined when validation passes.
Reusable validation rules
Create reusable validation rule functions by wrapping validate():
function url(field: any, options?: { message?: string }) { validate(field, ({value}) => { try { new URL(value()) return null } catch { return { kind: 'url', message: options?.message || 'Enter a valid URL' } } })}function phoneNumber(field: any, options?: { message?: string }) { validate(field, ({value}) => { const phoneRegex = /^\d{3}-\d{3}-\d{4}$/ if (!phoneRegex.test(value())) { return { kind: 'phoneNumber', message: options?.message || 'Phone must be in format: 555-123-4567' } } return null })}
You can use custom validation rules just like built-in validation rules:
urlForm = form(this.urlModel, (schemaPath) => { url(schemaPath.website, { message: 'Please enter a valid website URL' }) phoneNumber(schemaPath.phone)})
Cross-field validation
Cross-field validation compares or relates multiple field values.
A common scenario for cross-field validation is password confirmation:
import { Component, signal } from '@angular/core'import { form, Field, required, minLength, validate } from '@angular/forms/signals'@Component({ selector: 'app-password-change', imports: [Field], template: ` <form> <label> New Password <input type="password" [field]="passwordForm.password" /> </label> <label> Confirm Password <input type="password" [field]="passwordForm.confirmPassword" /> </label> <button type="submit">Change Password</button> </form> `})export class PasswordChangeComponent { passwordModel = signal({ password: '', confirmPassword: '' }) passwordForm = form(this.passwordModel, (schemaPath) => { required(schemaPath.password, { message: 'Password is required' }) minLength(schemaPath.password, 8, { message: 'Password must be at least 8 characters' }) required(schemaPath.confirmPassword, { message: 'Please confirm your password' }) validate(schemaPath.confirmPassword, ({value, valueOf}) => { const confirmPassword = value() const password = valueOf(schemaPath.password) if (confirmPassword !== password) { return { kind: 'passwordMismatch', message: 'Passwords do not match' } } return null }) })}
The confirmation validation rule accesses the password field value using valueOf(schemaPath.password) and compares it to the confirmation value. This validation rule runs reactively - if either password changes, validation reruns automatically.
Async validation
Async validation handles validation that requires external data sources, like checking username availability on a server or validating against an API.
Using validateHttp()
The validateHttp() function performs HTTP-based validation:
import { Component, signal, inject } from '@angular/core'import { HttpClient } from '@angular/common/http'import { form, Field, required, validateHttp } from '@angular/forms/signals'@Component({ selector: 'app-username-form', imports: [Field], template: ` <form> <label> Username <input [field]="usernameForm.username" /> @if (usernameForm.username().pending()) { <span class="checking">Checking availability...</span> } </label> </form> `})export class UsernameFormComponent { http = inject(HttpClient) usernameModel = signal({ username: '' }) usernameForm = form(this.usernameModel, (schemaPath) => { required(schemaPath.username, { message: 'Username is required' }) validateHttp(schemaPath.username, { request: ({value}) => `/api/check-username?username=${value()}`, onSuccess: (response: any) => { if (response.taken) { return { kind: 'usernameTaken', message: 'Username is already taken' } } return null }, onError: (error) => ({ kind: 'networkError', message: 'Could not verify username availability' }) }) })}
The validateHttp() validation rule:
- Calls the URL or request returned by the
requestfunction - Maps the successful response to a validation error or
nullusingonSuccess - Handles request failures (network errors, HTTP errors) using
onError - Sets
pending()totruewhile the request is in progress - Only runs after all synchronous validation rules pass
Pending state
While async validation runs, the field's pending() signal returns true. Use this to show loading indicators:
@if (form.username().pending()) { <span class="spinner">Checking...</span>}
The valid() signal returns false while validation is pending, even if there are no errors yet. The invalid() signal only returns true if errors exist.
Next steps
This guide covered creating and applying validation rules. Related guides explore other aspects of Signal Forms:
- Form Models guide - Creating and updating form models