Article
Angular 21.2: Reactive Zod Schemas in Signal Forms
Angular 21.2 introduces a powerful enhancement to Signal Forms: reactive Zod schemas. The validateStandardSchema() function can now accept computed schemas that depend on signals, enabling validation rules to update reactively as your form state changes.
This feature solves a common problem: validation schemas were previously static, defined once when the form was created. Now, schemas can evolve dynamically based on user input or external state, making it easier to build forms with context-dependent validation rules.
The Problem: Static Validation Schemas
Before Angular 21.2, when you used validateStandardSchema() with a Zod schema, the schema was captured once during form initialization. This meant validation rules couldn't adapt to changing form values or external signals.
Consider a bank transfer form where the maximum transfer amount depends on the selected country:
// ❌ Before Angular 21.2 - Static schemaconst transferForm = form( signal({ country: '', amount: 0 }), (schemaPath) => { // Schema is fixed - can't change based on country selection validateStandardSchema(schemaPath, z.object({ country: z.string().min(1), amount: z.number().max(10000), // Fixed limit - not ideal! })); });If different countries have different transfer limits, you'd need workarounds like custom validators or manual validation logic. The schema couldn't react to the country field changing.
The Solution: Reactive Schemas
Angular 21.2 allows validateStandardSchema() to accept a LogicFn—a function that receives the field context and returns a schema. This enables two powerful capabilities:
-
External signals in computed schemas: You can use external signals inside your Zod schema, and the schema itself can be exposed as a computed signal that updates reactively.
-
Accessing form model context: The schema function receives a
ctxparameter (FieldContext) that provides access to other form values viactx.valueOf(), allowing validation rules to depend on other fields in the form.
Let's explore each capability with real-world examples.
Feature 1: External Signals in Computed Schemas
The first capability allows you to use external signals within your Zod schema and expose the schema as a computed signal. When the external signals change, the schema updates automatically, triggering revalidation.
Real-World Example: Dynamic Password Requirements
Consider a user registration form where password requirements (like minimum length) can be configured dynamically, perhaps loaded from a service or set by an admin:
import { Component, computed, signal } from '@angular/core';import { form, validateStandardSchema, submit } from '@angular/forms/signals';import * as z from 'zod';
interface RegistrationData { email: string; password: string; confirmPassword: string;}
@Component({ selector: 'app-registration-form', // ... template and imports ...})export class RegistrationFormComponent { // External signal for password requirements (could come from a service) minPasswordLength = signal(8);
// Computed schema that depends on the external signal registrationSchema = computed(() => z.object({ email: z.string().email({ message: 'Invalid email address' }), password: z.string() .min(this.minPasswordLength(), { message: `Password must be at least ${this.minPasswordLength()} characters`, }) .regex(/[A-Z]/, { message: 'Password must contain an uppercase letter' }) .regex(/[0-9]/, { message: 'Password must contain a number' }), confirmPassword: z.string().min(1, { message: 'Please confirm your password' }), }) .refine((data) => data.password === data.confirmPassword, { message: 'Passwords do not match', path: ['confirmPassword'], }) );
registrationModel = signal<RegistrationData>({ email: '', password: '', confirmPassword: '', });
registrationForm = form(this.registrationModel, (schemaPath) => { validateStandardSchema(schemaPath, () => this.registrationSchema()); });
onSubmit(event: Event) { event.preventDefault(); submit(this.registrationForm, async () => { const data = this.registrationModel(); console.log('Registering:', data); // Send to API... }); }
// Example: Admin can update password requirements updatePasswordRequirements(newLength: number) { this.minPasswordLength.set(newLength); // The schema automatically updates, and validation re-runs }}How it works:
minPasswordLengthis an external signal that can be updated from anywhere (e.g., an admin panel, configuration service).registrationSchemais a computed signal that readsminPasswordLength()and creates a Zod schema with the current minimum length requirement.- When
validateStandardSchema()is called with() => this.registrationSchema(), Angular tracks the computed signal as a dependency. - When
minPasswordLengthchanges, the computed schema updates, and the form automatically revalidates with the new rules.
Benefits:
- ✅ Validation rules stay synchronized with external configuration
- ✅ No manual revalidation needed when requirements change
- ✅ Clean separation of concerns: form logic stays reactive
Feature 2: Accessing Form Model Context
The second capability allows the schema function to receive a ctx parameter (FieldContext) that provides access to other form field values via ctx.valueOf(). This enables validation rules that depend on other fields in the same form.
Real-World Example: Country-Based Transfer Limits
Let's build a bank transfer form where the maximum transfer amount depends on the selected country:
import { Component, signal } from '@angular/core';import { form, validateStandardSchema, submit } from '@angular/forms/signals';import * as z from 'zod';
interface TransferData { country: string; accountNumber: string; amount: number;}
// Transfer limits by countryconst TRANSFER_LIMITS: Record<string, number> = { 'US': 10000, 'CA': 5000, 'UK': 8000, 'FR': 6000,};
@Component({ selector: 'app-transfer-form', // ... template and imports ...})export class TransferFormComponent { transferModel = signal<TransferData>({ country: '', accountNumber: '', amount: 0, });
transferForm = form(this.transferModel, (schemaPath) => { // The schema function receives ctx, allowing access to other form values validateStandardSchema(schemaPath, (ctx) => { // Read the current country value from the form const country = ctx.valueOf(schemaPath.country); const maxAmount = country ? TRANSFER_LIMITS[country] : undefined;
return z.object({ country: z.string().min(1, { message: 'Please select a country' }), accountNumber: z.string() .min(8, { message: 'Account number must be at least 8 digits' }) .regex(/^\d+$/, { message: 'Account number must contain only digits' }), amount: z.number() .min(1, { message: 'Amount must be greater than 0' }) .max( maxAmount ?? Infinity, { message: maxAmount ? `Amount cannot exceed $${maxAmount} for ${country}` : 'Please select a country first', } ), }); }); });
onSubmit(event: Event) { event.preventDefault(); submit(this.transferForm, async () => { const data = this.transferModel(); console.log('Transferring:', data); // Send to API... }); }}How it works:
- The schema function receives a
ctxparameter (FieldContext) that provides access to form values viactx.valueOf(). - When the schema is evaluated,
ctx.valueOf(schemaPath.country)reads the current value of thecountryfield. - The function looks up the transfer limit for that country and creates a schema with the appropriate
max()constraint for theamountfield. - When the user changes the
countryfield, the schema function is called again, reading the new country value and updating the validation rules accordingly. - The
amountfield is automatically revalidated with the new limit.
Benefits:
- ✅ Validation rules can depend on other form field values
- ✅ No need for custom validators for simple cross-field dependencies
- ✅ Type-safe access to form values via
schemaPath - ✅ Reactive updates when dependent fields change
Combining Both Features
You can combine both features in a single schema. For example, you could have a form where validation depends on both external signals (like configuration) and other form fields (like user selections):
const externalConfig = signal({ maxTransactions: 10 });
const myForm = form(myModel, (schemaPath) => { validateStandardSchema(schemaPath, (ctx) => { const selectedOption = ctx.valueOf(schemaPath.selectedOption); const maxAllowed = externalConfig().maxTransactions;
return z.object({ selectedOption: z.string().min(1), transactionCount: z.number().max(maxAllowed), // ... other fields that depend on selectedOption }); });});Conditional Validation with Field Context
You can also use the field context to conditionally return different schemas or skip validation entirely:
type FormModel = { type: 'email' | 'phone'; value: string;};
const model = signal<FormModel>({ type: 'email', value: 'invalid' });
const nameForm = form(model, (schemaPath) => { validateStandardSchema(schemaPath, (ctx) => { const formValue = ctx.value();
if (formValue.type === 'email') { return z.object({ type: z.literal('email'), value: z.string().email({ message: 'Invalid email address' }), }); } else { return z.object({ type: z.literal('phone'), value: z.string().regex(/^\d{3}-\d{3}-\d{4}$/, { message: 'Phone must be in format: 555-123-4567', }), }); } });});Skipping Validation Conditionally
You can return undefined to skip validation entirely under certain conditions:
const form = form(model, (schemaPath) => { validateStandardSchema(schemaPath, (ctx) => { const formValue = ctx.value();
// Skip validation when disabled if (!formValue.validationEnabled) { return undefined; }
return z.object({ validationEnabled: z.boolean(), name: z.string().min(3), }); });});Technical Details
API Changes
The validateStandardSchema() function signature now accepts:
validateStandardSchema<TSchema, TModel>( path: SchemaPath<TModel> & SchemaPathTree<TModel>, schema: StandardSchemaV1<TSchema> | LogicFn<TModel, StandardSchemaV1<unknown> | undefined>): voidThe LogicFn type is a function that receives a FieldContext and returns a schema or undefined:
type LogicFn<TModel, TResult> = (ctx: FieldContext<TModel>) => TResult;FieldContext API
The FieldContext object provides access to:
value(): Signal containing the current field valuestate: FieldState referencefield: FieldTree referencevalueOf(path): Get the value of another field by pathstateOf(path): Get the state of another field by pathfieldTreeOf(path): Get the field tree of another field by pathpathKeys: Signal with path keys from root to current field
Performance Considerations
The schema function is called reactively whenever:
- The form value changes
- Any signal accessed within the function changes
- The field becomes interactive (not hidden/disabled)
Angular's change detection ensures validation only runs when necessary, maintaining good performance even with complex reactive schemas.
Migration Guide
If you're currently using validateStandardSchema() with a static schema, no changes are required—your existing code continues to work. To take advantage of reactive schemas:
- Wrap your schema in a function that receives the field context:
// BeforevalidateStandardSchema(schemaPath, myZodSchema);
// AftervalidateStandardSchema(schemaPath, () => myZodSchema);- Access form values using
ctx.valueOf():
validateStandardSchema(schemaPath, (ctx) => { const country = ctx.valueOf(schemaPath.country); return z.object({ amount: z.number().max(getLimitForCountry(country)), });});- Use computed signals for schemas that depend on external state:
const dynamicSchema = computed(() => z.object({...}));validateStandardSchema(schemaPath, () => dynamicSchema());When to Use Reactive Schemas
Reactive schemas are perfect for:
- ✅ Context-dependent validation: Rules that change based on other form fields
- ✅ External state dependencies: Schemas that depend on signals outside the form
- ✅ Dynamic constraints: Validation limits that change over time
- ✅ Conditional validation: Different schemas based on form state
- ✅ Multi-step forms: Validation rules that evolve as users progress
For simple, static validation rules, static schemas remain the simpler choice.
Reactive Zod Schemas vs Custom Validators
While custom validation rules can achieve similar results for reactive validation, reactive Zod schemas offer distinct advantages:
Reactive Zod Schemas:
- ✅ Quick overview: All basic validation rules are visible in one place—the Zod schema definition
- ✅ Declarative: Validation rules are expressed declaratively, making them easier to read and maintain
- ✅ Type-safe: Full TypeScript support with Zod's type inference
- ✅ Standardized: Uses Zod's well-known validation API, consistent across your codebase
- ✅ Composable: Easy to combine multiple validation rules using Zod's fluent API
Custom Validators:
- ✅ More flexibility: Can implement complex validation logic that may be difficult to express in Zod
- ✅ Fine-grained control: Direct access to form state and field-level validation
- ✅ Performance: Can be optimized for specific use cases
For most scenarios involving reactive validation rules that depend on form values or external signals, reactive Zod schemas provide a cleaner, more maintainable solution. Custom validators remain the better choice when you need complex validation logic that doesn't fit well into a schema-based approach.
Summary
Angular 21.2's reactive Zod schemas feature makes Signal Forms even more powerful by enabling validation rules to adapt dynamically to form state and external signals. This eliminates the need for complex workarounds when building forms with context-dependent validation.
The feature maintains backward compatibility—existing static schemas continue to work unchanged. When you need reactive validation, simply wrap your schema in a function and access form values through the field context.
For more information, see the Signal Forms validation documentation and the form models guide.
Related Resources: