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
Zod Schemas (Client-Side)
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.
// 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
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().
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
| Rule | Zod (Frontend) | FluentValidation (Backend) |
|---|---|---|
| Required | z.string().min(1) | .NotEmpty() |
| z.string().email() | .EmailAddress() | |
| Min length | z.string().min(n) | .MinimumLength(n) |
| Max length | z.string().max(n) | .MaximumLength(n) |
| Regex | z.string().regex(r) | .Matches(r) |
| UUID | z.string().uuid() | Custom validator |
| Number range | z.number().min(n).max(m) | .InclusiveBetween(n, m) |
| Enum | z.enum([...]) | .IsInEnum() |
| Custom | z.refine(fn) | .Must(fn) / .MustAsync(fn) |
| Cross-field | z.refine() on parent | .Must() with context |
| Unique (async) | Custom hook | .MustAsync() with repo query |