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 in Signal Forms is defined through a schema function passed as the second argument to form().
The schema function receives a SchemaPathTree object that lets you define your validation rules:
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ChangeDetectionStrategy, Component, signal} from '@angular/core';
import {email, form, FormField, required, submit} from '@angular/forms/signals';
interface LoginData {
email: string;
password: string;
}
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [FormField],
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, {
action: 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.
Validation in Signal Forms follows this pattern:
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 rules execute in this order:
valid(), invalid(), errors(), and pending() signals updateSynchronous 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.
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.
The required() validation rule ensures a field has a value:
import {Component, signal} from '@angular/core';
import {form, FormField, required} from '@angular/forms/signals';
@Component({
selector: 'app-registration',
imports: [FormField],
template: `
<form novalidate>
<label>
Username
<input [formField]="registrationForm.username" />
</label>
<label>
Email
<input type="email" [formField]="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 | '' |
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.
NOTE: required will return true for empty array. Use minLength() to validate arrays.
The email() validation rule checks for valid email format:
import {Component, signal} from '@angular/core';
import {form, FormField, email} from '@angular/forms/signals';
@Component({
selector: 'app-contact',
imports: [FormField],
template: `
<form novalidate>
<label>
Your Email
<input type="email" [formField]="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 [email protected] but rejects malformed addresses like user@ or @example.com.
The min() and max() validation rules work with numeric values:
import {Component, signal} from '@angular/core';
import {form, FormField, min, max} from '@angular/forms/signals';
@Component({
selector: 'app-age-form',
imports: [FormField],
template: `
<form novalidate>
<label>
Age
<input type="number" [formField]="ageForm.age" />
</label>
<label>
Rating (1-5)
<input type="number" [formField]="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',
});
}); The minLength() and maxLength() validation rules work with strings and arrays:
import {Component, signal} from '@angular/core';
import {form, FormField, minLength, maxLength} from '@angular/forms/signals';
@Component({
selector: 'app-password-form',
imports: [FormField],
template: `
<form novalidate>
<label>
Password
<input type="password" [formField]="passwordForm.password" />
</label>
<label>
Bio
<textarea [formField]="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.
The pattern() validation rule validates against a regular expression:
import {Component, signal} from '@angular/core';
import {form, FormField, pattern} from '@angular/forms/signals';
@Component({
selector: 'app-phone-form',
imports: [FormField],
template: `
<form novalidate>
<label>
Phone Number
<input [formField]="phoneForm.phone" placeholder="555-123-4567" />
</label>
<label>
Postal Code
<input [formField]="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 |
Forms can include arrays of nested objects (for example, a list of order items). To apply validation rules to each item in an array, use applyEach() inside your schema function. applyEach() iterates the array path and supplies a path for each item where you can apply validators just like top-level fields.
import {Component, signal} from '@angular/core';
import {applyEach, FormField, form, min, required, SchemaPathTree} from '@angular/forms/signals';
type Item = {name: string; quantity: number};
interface Order {
title: string;
description: string;
items: Item[];
}
function ItemSchema(item: SchemaPathTree<Item>) {
required(item.name, {message: 'Item name is required'});
min(item.quantity, 1, {message: 'Quantity must be at least 1'});
}
@Component(/* ... */)
export class OrderComponent {
orderModel = signal<Order>({
title: '',
description: '',
items: [{name: '', quantity: 0}],
});
orderForm = form(this.orderModel, (schemaPath) => {
required(schemaPath.title);
required(schemaPath.description);
applyEach(schemaPath.items, ItemSchema);
});
} When validation rules fail, they produce error objects that describe what went wrong. Understanding error structure helps you provide clear feedback to users.
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.
All built-in validation rules accept a message option for custom error text:
import {Component, signal} from '@angular/core';
import {form, FormField, required, minLength} from '@angular/forms/signals';
@Component({
selector: 'app-signup',
imports: [FormField],
template: `
<form novalidate>
<label>
Username
<input [formField]="signupForm.username" />
</label>
<label>
Password
<input type="password" [formField]="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".
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.
While built-in validation rules handle common cases, you'll often need custom validation logic for business rules, complex formats, or domain-specific constraints.
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, FormField, validate} from '@angular/forms/signals';
@Component({
selector: 'app-url-form',
imports: [FormField],
template: `
<form novalidate>
<label>
Website URL
<input [formField]="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.
The validateTree() function creates custom validation rules that can target multiple fields or provide complex validation logic for a whole subtree.
import {Component, model} from '@angular/core';
import {form, FormField, validateTree} from '@angular/forms/signals';
interface User {
firstName: string;
lastName: string;
}
@Component({
/* ... */
})
export class UserFormComponent {
readonly userModel = model<DTO>({
firstName: '',
lastName: '',
});
userForm = form(this.userModel, (path) => {
validateTree(path, (ctx) => {
if (ctx.valueOf(path.firstName).length < 5) {
return {
kind: 'minLength5',
message: 'First name must be at least 5 characters',
fieldTree: ctx.fieldTree.lastName,
};
}
return null;
});
});
}
The validateTree() validator function receives the same FieldContext object as validate().
Create reusable validation rule functions by wrapping validate():
function url(path: SchemaPath<string>, options?: {message?: string}) {
validate(path, ({value}) => {
try {
new URL(value());
return null;
} catch {
return {
kind: 'url',
message: options?.message || 'Enter a valid URL',
};
}
});
}
function phoneNumber(path: SchemaPath<string>, options?: {message?: string}) {
validate(path, ({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 compares or relates multiple field values.
A common scenario for cross-field validation is password confirmation:
import {Component, signal} from '@angular/core';
import {form, FormField, required, minLength, validate} from '@angular/forms/signals';
@Component({
selector: 'app-password-change',
imports: [FormField],
template: `
<form novalidate>
<label>
New Password
<input type="password" [formField]="passwordForm.password" />
</label>
<label>
Confirm Password
<input type="password" [formField]="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 handles validation that requires external data sources, like checking username availability on a server or validating against an API.
The validateHttp() function performs HTTP-based validation:
import {Component, signal} from '@angular/core';
import {form, FormField, required, validateHttp} from '@angular/forms/signals';
@Component({
selector: 'app-username-form',|
imports: [FormField],
template: `
<form novalidate>
<label>
Username
<input [formField]="usernameForm.username" />
@if (usernameForm.username().pending()) {
<span class="checking">Checking availability...</span>
}
</label>
</form>
`,
})
export class UsernameFormComponent {
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:
request functionnull using onSuccess
onError
pending() to true while the request is in progressWhile 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.
Signal Forms have built-in support for libraries that conform to Standard Schema like Zod or Valibot. The integration is provided via the validateStandardSchema function. This allows you to use existing schemas while maintaining Signal Forms' reactive validation benefits.
import {form, validateStandardSchema} from '@angular/forms/signals';
import * as z from 'zod';
// Define your schema
const userSchema = z.object({
email: z.email(),
password: z.string().min(8),
});
// Use with Signal Forms
const userForm = form(signal({email: '', password: ''}), (schemaPath) => {
validateStandardSchema(schemaPath, userSchema);
}); You can pass a signal instead of a static schema so the validation schema updates automatically when its dependencies change.
import {Component, computed, signal} from '@angular/core';
import {form, FormField, validateStandardSchema} from '@angular/forms/signals';
import z from 'zod';
@Component({
/* ... */
})
export class DynamicSchema {
model = signal({document: '', type: 'dni'});
// Schema reacts automatically to type changes
schema = computed(() =>
z.object({
document:
this.model().type === 'dni'
? z.string().length(8, 'DNI must be 8 digits')
: z.string().min(12, 'Passport must be at least 12 characters'),
}),
);
f = form(this.model, (p) => validateStandardSchema(p, () => this.schema()));
} This guide covered creating and applying validation rules. Related guides explore other aspects of Signal Forms:
Super-powered by Google ©2010–2025.
Code licensed under an MIT-style License. Documentation licensed under CC BY 4.0.
https://angular.dev/guide/forms/signals/validation