typescript 125 lines · 3 tabs

Field-Level Signup Validation with Zod and a Typed useZodForm Hook

Shared by codesnips Jul 2026
3 tabs
import { z } from 'zod';

export const signupSchema = z
  .object({
    email: z.string().min(1, 'Email is required').email('Enter a valid email'),
    username: z
      .string()
      .min(3, 'Username must be at least 3 characters')
      .regex(/^[a-z0-9_]+$/i, 'Only letters, numbers and underscores'),
    password: z.string().min(8, 'Password must be at least 8 characters'),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: 'Passwords do not match',
    path: ['confirmPassword'],
  });

export type SignupValues = z.infer<typeof signupSchema>;
3 files · typescript Explain with highlit

This snippet shows how a signup form validates input with Zod and surfaces per-field errors without pulling in a full form library. The whole thing hangs off a single schema, so the runtime validation and the compile-time types stay in sync.

In signupSchema, the shape of the form is declared once with z.object. Each field carries its own message (z.string().email(...), .min(8, ...)), and a cross-field rule is expressed with .refine, whose path: ['confirmPassword'] attaches the failure to a specific field rather than the form root. Exporting SignupValues via z.infer means the component never hand-writes the value type — it is derived from the schema, so adding a field can only happen in one place.

The reusable logic lives in useZodForm hook. It keeps values and an errors map keyed by field name. The important detail is flattenErrors: ZodError exposes error.issues, each with a path array, and the helper walks those into a flat { field: message } record so the UI can look up a message by name. validate runs safeParse, which never throws — it returns a discriminated union with a success boolean, letting the hook branch cleanly. On success it clears errors and returns the parsed data; on failure it stores the flattened map and returns null. The setField callback also clears that field's error on edit, which is the small touch that makes the form feel responsive instead of nagging.

SignupForm wires it together. handleSubmit calls validate, and only proceeds to onSubmit when parsed data comes back non-null, so the network call receives already-validated, correctly typed values. Each input reads its message from errors[name] and sets aria-invalid plus aria-describedby, which keeps the error announced to screen readers, not just shown visually.

The trade-off worth noting is that this validates on submit rather than on every keystroke; per-field live validation would mean calling the schema's field-level parsers on blur. The pattern is a good fit when a project wants one source of truth for both types and rules and prefers a thin hook over a heavier dependency. A pitfall to watch: .refine errors only surface if their path matches a real field, otherwise the message quietly lands on the form root.


Related snips

typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
ruby
class SignupForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :account_name, :string
  attribute :email, :string

Shallow Controller, Deep Params: Form Object Pattern

rails activemodel form-object
by codesnips 3 tabs
javascript
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
  const success = true;

  setTimeout(() => {
    if (success) {

Promises and async/await patterns for asynchronous JavaScript

javascript promises async-await
by Alex Chang 1 tab
javascript
import { Controller } from "@hotwired/stimulus"
import Mousetrap from "mousetrap"

export default class extends Controller {
  connect() {
    // Global shortcuts

Keyboard shortcuts with Stimulus and Mousetrap

stimulus javascript ux
by Jordan Lee 2 tabs
typescript
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
  timeout: 15000,

Axios API client with interceptors

react axios api
by Maya Patel 1 tab
typescript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface FilterState {
  search: string
  category: string | null

Zustand for lightweight state management

react zustand state-management
by Maya Patel 2 tabs

Share this code

Here's the card — post it anywhere.

Field-Level Signup Validation with Zod and a Typed useZodForm Hook — share card
Link copied