Schema Validation Zod Validation

Zod Validation

Use Zod schemas to validate your dynamic forms. This lets you reuse existing schemas, share validation logic between frontend and backend, and leverage Zod's powerful cross-field validation like .refine().

Installation

npm install zod

Basic Usage

import { z } from 'zod';
import { standardSchema } from '@ng-forge/dynamic-forms/schema';

const userSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
});

const config = {
  schema: standardSchema(userSchema),
  fields: [
    { key: 'email', type: 'input', label: 'Email', props: { type: 'email' } },
    { key: 'password', type: 'input', label: 'Password', props: { type: 'password' } },
    { key: 'submit', type: 'submit', label: 'Register' },
  ],
} as const satisfies FormConfig;

The standardSchema() wrapper tells Dynamic Forms to use Zod for validation. Errors are automatically mapped to the corresponding form fields.

Live Demo

Try the password confirmation form with Zod validation:

Cross-Field Validation

The main reason to use Zod is cross-field validation - rules that depend on multiple fields.

Password Confirmation with .refine()

const passwordSchema = z
  .object({
    password: z.string().min(8, 'Password must be at least 8 characters'),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: 'Passwords must match',
    path: ['confirmPassword'], // Error appears on this field
  });

Date Range Validation

const dateRangeSchema = z
  .object({
    startDate: z.string(),
    endDate: z.string(),
  })
  .refine((data) => new Date(data.endDate) >= new Date(data.startDate), {
    message: 'End date must be after start date',
    path: ['endDate'],
  });

Multiple Cross-Field Rules with .superRefine()

const registrationSchema = z
  .object({
    password: z.string().min(8),
    confirmPassword: z.string(),
    email: z.string().email(),
    confirmEmail: z.string().email(),
  })
  .superRefine((data, ctx) => {
    if (data.password !== data.confirmPassword) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: 'Passwords must match',
        path: ['confirmPassword'],
      });
    }
    if (data.email !== data.confirmEmail) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: 'Emails must match',
        path: ['confirmEmail'],
      });
    }
  });

Conditional Required Fields

const contactSchema = z
  .object({
    contactMethod: z.enum(['email', 'phone']),
    email: z.string().optional(),
    phone: z.string().optional(),
  })
  .refine(
    (data) => {
      if (data.contactMethod === 'email') return !!data.email;
      if (data.contactMethod === 'phone') return !!data.phone;
      return true;
    },
    (data) => ({
      message: `${data.contactMethod} is required`,
      path: [data.contactMethod],
    }),
  );

Reusing Schemas

From OpenAPI or Backend

// Reuse schemas generated from OpenAPI specs
import { UserCreateSchema } from './generated/schemas';

const config = {
  schema: standardSchema(UserCreateSchema),
  fields: [
    { key: 'username', type: 'input', label: 'Username' },
    { key: 'email', type: 'input', label: 'Email' },
    { key: 'password', type: 'input', label: 'Password', props: { type: 'password' } },
  ],
} as const satisfies FormConfig;

Shared Validation Logic

// schemas/user.schema.ts - shared with backend
export const userSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Password too short'),
});

// frontend form
import { userSchema } from '@shared/schemas';

const config = {
  schema: standardSchema(userSchema),
  fields: [
    { key: 'email', type: 'input', label: 'Email' },
    { key: 'password', type: 'input', label: 'Password', props: { type: 'password' } },
  ],
} as const satisfies FormConfig;

How Errors Work

Zod errors automatically map to form fields via the path property:

// When validation fails, Zod produces:
{
  issues: [{ path: ['confirmPassword'], message: 'Passwords must match' }];
}

// Dynamic Forms maps this to Angular's form errors:
form.controls.confirmPassword.errors;
// → { 'Passwords must match': true }

Combining with Field Validators

Use Zod for cross-field rules, field-level validators for single-field rules:

const config = {
  // Zod handles cross-field validation
  schema: standardSchema(
    z.object({ password: z.string(), confirm: z.string() }).refine((data) => data.password === data.confirm, {
      message: 'Passwords must match',
      path: ['confirm'],
    }),
  ),

  // Field validators handle single-field rules
  fields: [
    { key: 'password', type: 'input', label: 'Password', required: true, minLength: 8 },
    { key: 'confirm', type: 'input', label: 'Confirm Password', required: true },
  ],
} as const satisfies FormConfig;

Other Schema Libraries

The same approach works with Valibot and ArkType:

// Valibot
import * as v from 'valibot';
const schema = v.object({ email: v.pipe(v.string(), v.email()) });
const config = { schema: standardSchema(schema), fields: [...] };

// ArkType
import { type } from 'arktype';
const schema = type({ email: 'email' });
const config = { schema: standardSchema(schema), fields: [...] };