How to Use Zod with Angular Signal Forms (Step-by-Step Migration)

Published: (December 12, 2025 at 03:00 AM EST)
4 min read
Source: Dev.to

Source: Dev.to

The Problem: Zod Validation Needs to Be Re‑Integrated After Signal Forms Migration

Many developers encounter this exact issue: a form works perfectly with Reactive Forms and Zod validation, but after migrating to Signal Forms the validation stops working. The form submits even when fields are invalid, and error messages disappear.

Zod needs to be re‑integrated using Signal Forms’ validation system. We’ll first understand the pieces involved, then migrate them properly.

What Is Zod? Why Angular Developers Use It for Validation

If you’re new to Zod, here’s a quick overview:

  • Type safety – TypeScript types stay in sync with the validation schema.
  • Runtime validation – Catches errors that TypeScript can’t catch at compile time.
  • Clean error messages – Human‑readable validation errors out of the box.
  • Composable schemas – Build complex validation rules from simple building blocks.

For Angular developers, Zod centralizes validation logic outside of Angular’s form system, making it easier to share rules between frontend and backend or reuse schemas across different parts of the application.

Install Zod like any other npm package:

npm install zod

(Assuming it’s already installed, we can jump straight into the code.)

Understanding the Zod Schema for Angular Form Validation

Schema file (form.schema.ts)

import { z } from 'zod';

Type inference

export type SignupModel = z.infer;

Error map type

export type ZodErrorMap = Record;

Validation schema

export const signupSchema = z.object({
  username: z
    .string()
    .min(3, 'Username must be at least 3 characters long')
    .regex(
      /^[a-zA-Z0-9_]+$/,
      'Only letters, numbers, and underscores are allowed'
    ),
  email: z.string().email('Please enter a valid email address'),
});

Validation helper

export function validateSignup(value: SignupModel) {
  const result = signupSchema.safeParse(value);

  if (result.success) {
    return {
      success: true as const,
      data: result.data,
      errors: {} as ZodErrorMap,
    };
  }

  const errors = result.error.issues.reduce((acc, issue) => {
    const field = issue.path[0]?.toString() ?? '_form';
    (acc[field] ??= []).push(issue.message);
    return acc;
  }, {});

  return {
    success: false as const,
    data: null,
    errors,
  };
}

The validateSignup function returns either a successful result with validated data or a failure with a clean ZodErrorMap that we can display in the UI.

How Zod Is Wired into Angular Reactive Forms (Before Migration)

Template (Reactive Forms)

@let usernameErrors = getZodErrors('username');

@if (usernameErrors.length && form.controls.username.touched) {
  @for (err of usernameErrors; track $index) {
    - {{ err }}
  }
}

@let emailErrors = getZodErrors('email');

@if (emailErrors.length && form.controls.email.touched) {
  @for (err of emailErrors; track $index) {
    - {{ err }}
  }
}

<button type="submit">Submit</button>
  • formGroup binds the template to a Reactive Form instance.
  • formControlName connects each input to its corresponding FormControl.
  • getZodErrors(fieldName) retrieves the array of Zod error messages for a given field.
  • Errors are displayed only when the field has been touched and there are messages.

Component TypeScript: Reactive Forms Approach

import { Component, inject } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { ZodErrorMap, validateSignup, SignupModel } from './form.schema';

@Component({
  selector: 'app-signup',
  templateUrl: './form.component.html',
})
export class FormComponent {
  private fb = inject(FormBuilder);

  // Store Zod validation errors
  zodErrors: ZodErrorMap = {};

  // Reactive form definition
  form = this.fb.group({
    username: ['', [Validators.required]],
    email: ['', [Validators.required, Validators.email]],
  });

  // Helper to retrieve errors for a specific field
  getZodErrors(field: keyof SignupModel): string[] {
    return this.zodErrors[field] ?? [];
  }

  // Submit handler
  onSubmit(): void {
    const value = this.form.value as SignupModel;
    const result = validateSignup(value);

    if (result.success) {
      // Handle successful submission (e.g., send to server)
      console.log('Form data is valid:', result.data);
    } else {
      // Populate Zod errors so the template can display them
      this.zodErrors = result.errors;
      // Optionally mark controls as touched to trigger UI feedback
      this.form.markAllAsTouched();
    }
  }
}
  • zodErrors holds the error map returned by validateSignup.
  • getZodErrors is used in the template to fetch field‑specific messages.
  • On submit, the component validates the form data with Zod, updates zodErrors, and marks controls as touched to ensure error messages appear.

The next steps (not shown here) involve replacing the Reactive Form setup with Angular Signal Forms, using validateTree() to bridge Zod’s error map into the Signal Forms validation API.

Back to Blog

Related posts

Read more »

Angular - Standalone Component

Overview Angular standalone components enable you to build components without an NgModule, simplifying application structure and improving modularity. Introduc...