Form Validation

Zod schemas, React Hook Form integration, FluentValidation server-side, and error handling patterns.

Form validation in SCRIPE follows a dual-layer approach: client-side validation with Zod schemas and React Hook Form provides instant feedback, while server-side FluentValidation in the SCRIPE mediator pipeline ensures data integrity.

Validation Architecture

Validation Architecture (Frontend + Backend)
Frontend Validation
Zod Schemas — Type-safe schema definitions
zodResolver
React Hook Form — Form state + zodResolver
API call
Backend Validation
FluentValidation — Server-side validators
ValidationBehavior (AstraFlow mediator)

Zod Schemas (Client-Side)

Domain Entity Schema — AdminTypeScript
import { z } from 'zod';

// Domain entity schema (in domain/entities/)
/**
 * Exported constant defining parameters and fields for admin schema configurations.
 */
export const AdminSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  firstName: z.string().min(2).max(50),
  lastName: z.string().min(2).max(50),
  phoneNumber: z.string().optional(),
  role: z.object({
    id: z.string().uuid(),
    name: z.string(),
  }),
  isActive: z.boolean(),
  isBlocked: z.boolean(),
  createdAt: z.string().datetime(),
  lastLoginAt: z.string().datetime().optional(),
});

/**
 * Exported type defining parameters and fields for admin configurations.
 */
export type Admin = z.infer<typeof AdminSchema>;

// Form-specific schemas (in presentation/viewmodels/)
/**
 * Exported constant defining parameters and fields for create admin schema configurations.
 */
export const CreateAdminSchema = z.object({
  email: z.string().email("Invalid email address"),
  firstName: z.string().min(2, "Must be at least 2 characters"),
  lastName: z.string().min(2, "Must be at least 2 characters"),
  password: z.string()
    .min(8, "Must be at least 8 characters")
    .regex(/[A-Z]/, "Must contain an uppercase letter")
    .regex(/[0-9]/, "Must contain a number")
    .regex(/[^A-Za-z0-9]/, "Must contain a special character"),
  confirmPassword: z.string(),
  roleId: z.string().uuid("Please select a role"),
  phoneNumber: z.string().optional(),
}).refine(data => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ["confirmPassword"],
});

/**
 * Exported type defining parameters and fields for create admin input configurations.
 */
export type CreateAdminInput = z.infer<typeof CreateAdminSchema>;

Zod Schema Builders & Safe API Parsing

Zod forms leverage shared builders like emailField, strongPassword, requiredStr, and cronField. During API mapping, safeParseApiResponse parses responses safely to prevent crashes due to backend schema drift, falling back to safe defaults.

zod-form-utils.ts & zod-utils.ts — Shared Validation HelpersTypeScript
// 1. Shared Zod builders for react-hook-form inputs
/**
 * Documentation for z.object
 */
export const signupSchema = z.object({
  email: emailField(),
  password: strongPassword(),
  workspace: requiredStr({ min: 3, max: 100, label: "Workspace Name" }),
  cron: cronField(),
});

// 2. Safe API parsing to prevent UI crashes on API contract drift
/**
 * Documentation for module export
 */
export function toEntity(dto: UserDto): User {
  const result = safeParseApiResponse(UserSchema, dto, "UserDto mapping warning");
  
  // Degrades gracefully by returning fallback entity even if parsing fails validation
  return new User({
    id: result.id,
    email: result.email ?? "",
    firstName: result.firstName ?? "Unnamed",
    isActive: result.isActive ?? false,
  });
}

React Hook Form Integration

Form ViewModel with zodResolverTypeScript
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

/**
 * React hook/ViewModel orchestrating state and data flows for create admin view model.
 * Manages TanStack Query hooks, query cache keys, and repository fetch requests.
 */
export function useCreateAdminViewModel() {
  const form = useForm<CreateAdminInput>({
    resolver: zodResolver(CreateAdminSchema),
    defaultValues: {
      email: '',
      firstName: '',
      lastName: '',
      password: '',
      confirmPassword: '',
      roleId: '',
    },
    mode: 'onBlur',  // Validate on blur for better UX
  });

  const createMutation = useMutation({
    mutationFn: (data: CreateAdminInput) => adminRepo.create(data),
    onSuccess: () => {
      form.reset();
      toast.success(t('admins.createSuccess'));
    },
    onError: (error: ApiError) => {
      // Map server validation errors to form fields
      if (error.type === 'ValidationError') {
        error.errors.forEach(e => {
          form.setError(e.propertyName as any, {
            type: 'server',
            message: e.errorMessage,
          });
        });
      }
    },
  });

  return {
    form,
    onSubmit: form.handleSubmit(data => createMutation.mutate(data)),
    isSubmitting: createMutation.isPending,
  };
}

Server-Side Error Handling

When server-side FluentValidation rejects a request, the API returns structured error objects with field-level messages. The frontend maps these errors back to form fields using setError().

CreateAdminValidator.cs — FluentValidationC#
public class CreateAdminCommandValidator
    : AbstractValidator<CreateAdminCommand>
{
    public CreateAdminCommandValidator(IAdminRepository repo)
    {
        RuleFor(x => x.Email)
            .NotEmpty().WithMessage("Email is required")
            .EmailAddress().WithMessage("Invalid email format")
            .MustAsync(async (email, ct) =>
                !await repo.ExistsAsync(e => e.Email == email))
            .WithMessage("Email already registered");

        RuleFor(x => x.Password)
            .NotEmpty().MinimumLength(8)
            .Matches("[A-Z]").WithMessage("Uppercase required")
            .Matches("[0-9]").WithMessage("Digit required")
            .Matches("[^a-zA-Z0-9]").WithMessage("Special char required");

        RuleFor(x => x.FirstName)
            .NotEmpty().Length(2, 50);
    }
}

Validation Rules Reference

RuleZod (Frontend)FluentValidation (Backend)
Requiredz.string().min(1).NotEmpty()
Emailz.string().email().EmailAddress()
Min lengthz.string().min(n).MinimumLength(n)
Max lengthz.string().max(n).MaximumLength(n)
Regexz.string().regex(r).Matches(r)
UUIDz.string().uuid()Custom validator
Number rangez.number().min(n).max(m).InclusiveBetween(n, m)
Enumz.enum([...]).IsInEnum()
Customz.refine(fn).Must(fn) / .MustAsync(fn)
Cross-fieldz.refine() on parent.Must() with context
Unique (async)Custom hook.MustAsync() with repo query