Skip to main content
Sonamu uses Zod to validate TypeScript types at runtime. This document explains all features and patterns of Zod validation.

What is Zod?

Type-Safe Schema

Auto TypeScript type inference - No separate type definition needed

Runtime Validation

Actual data validation at execution - Block invalid data

Detailed Errors

Field-by-field error messages - Easy debugging

Transform & Defaults

Data transformation and defaults - Flexible handling

Basic Usage

Zod schemas are auto-generated from Entities, but you can also define them manually.

Schema Definition

import { z } from "zod";

// Auto-generated from Entity
export const User = z.object({
  id: z.number().int(),
  email: z.string().max(100),
  age: z.number().int(),
  role: z.enum(["admin", "normal"]),
});

export type User = z.infer<typeof User>;

Data Validation

import { User } from "./user.types";

try {
  const user = User.parse({
    id: 1,
    email: "john@test.com",
    age: 30,
    role: "admin",
  });
  // ✅ Validation success, user is User type
  console.log(user.email);
} catch (error) {
  // ❌ ZodError on validation failure
  console.error(error);
}
parse vs safeParse:
  • parse(): Throws errors. Handle with try-catch
  • safeParse(): Returns errors. Check with result.success
Use parse() in API handlers to automatically return 400 errors, and use safeParse() in UI for safe handling.

Basic Type Validation

Zod validation for each Entity type.

String Validation

const email = z.string();
email.parse("john@test.com");  // ✅

const withLength = z.string().max(100);
withLength.parse("a".repeat(100)); // ✅
withLength.parse("a".repeat(101)); // ❌

Number Validation

const age = z.number().int();
age.parse(30);  // ✅
age.parse(30.5);  // ❌ Must be integer

Date Validation

const birthDate = z.date();
birthDate.parse(new Date());  // ✅
birthDate.parse("2024-01-01");  // ❌ Expected Date, received string

Boolean Validation

const isActive = z.boolean();
isActive.parse(true); // ✅
isActive.parse(false); // ✅
isActive.parse("true"); // ❌ Expected boolean, received string

// Coerce: string → boolean
const coerced = z.coerce.boolean();
coerced.parse("true"); // ✅ true
coerced.parse("false"); // ✅ false
coerced.parse(1); // ✅ true
coerced.parse(0); // ✅ false

Complex Type Validation

Arrays

const tags = z.string().array();
tags.parse(["typescript", "nodejs"]);  // ✅
tags.parse(["typescript", 123]);  // ❌

Objects

const user = z.object({
  name: z.string(),
  profile: z.object({
    bio: z.string(),
    website: z.string().url().optional(),
  }),
});

user.parse({
  name: "John",
  profile: {
    bio: "Hello",
    website: "https://john.com",
  },
}); // ✅

Enums

const role = z.enum(["admin", "moderator", "normal"]);

role.parse("admin"); // ✅
role.parse("guest"); // ❌ Invalid enum value

Union (one of multiple types)

const idOrEmail = z.union([z.number().int(), z.string().email()]);

idOrEmail.parse(123); // ✅
idOrEmail.parse("john@test.com"); // ✅
idOrEmail.parse("not-an-email"); // ❌

Advanced Validation Patterns

Conditional Validation

const password = z.string()
  .min(8)
  .refine(
    (val) => /[A-Z]/.test(val),
    { message: "Must contain uppercase letter" }
  )
  .refine(
    (val) => /[0-9]/.test(val),
    { message: "Must contain number" }
  )
  .refine(
    (val) => /[!@#$%^&*]/.test(val),
    { message: "Must contain special character" }
  );

password.parse("Abc12345!"); // ✅
password.parse("abc12345"); // ❌ No uppercase

Data Transformation

const age = z.string().transform(val => parseInt(val, 10));

age.parse("30"); // ✅ 30 (number)

Default Values

const settings = z.object({
  theme: z.enum(["light", "dark"]).default("light"),
  notifications: z.boolean().default(true),
});

settings.parse({}); // ✅ { theme: "light", notifications: true }
settings.parse({ theme: "dark" }); // ✅ { theme: "dark", notifications: true }

Practical Validation Patterns

API Parameter Validation

export const UserListParams = z.object({
  num: z.number().int().min(1).max(100).default(24),
  page: z.number().int().min(1).default(1),
  search: z.enum(["id", "email", "username"]).optional(),
  keyword: z.string().trim().optional(),
  orderBy: z.enum(["id-desc", "id-asc", "created_at-desc"]).optional(),
}).refine(
  (data) => {
    // keyword requires search
    if (data.keyword && !data.search) {
      return false;
    }
    return true;
  },
  {
    message: "keyword and search must be used together",
    path: ["search"],
  }
);

Business Logic Validation

const dateRange = z.object({
  startDate: z.date(),
  endDate: z.date(),
}).refine(
  (data) => data.endDate > data.startDate,
  {
    message: "End date must be after start date",
    path: ["endDate"],
  }
).refine(
  (data) => {
    const diff = data.endDate.getTime() - data.startDate.getTime();
    const days = diff / (1000 * 60 * 60 * 24);
    return days <= 365;
  },
  {
    message: "Period can be maximum 1 year",
    path: ["endDate"],
  }
);

Form Validation

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { UserSaveParams } from "@/services/user/user.types";

function UserForm() {
  const form = useForm({
    resolver: zodResolver(UserSaveParams),
    defaultValues: {
      email: "",
      username: "",
      role: "normal",
    },
  });

  const onSubmit = form.handleSubmit((data) => {
    // data is automatically UserSaveParams type
    console.log(data);
  });

  return (
    <form onSubmit={onSubmit}>
      <input {...form.register("email")} />
      {form.formState.errors.email && (
        <span>{form.formState.errors.email.message}</span>
      )}

      <input {...form.register("username")} />
      {form.formState.errors.username && (
        <span>{form.formState.errors.username.message}</span>
      )}

      <button type="submit">Save</button>
    </form>
  );
}

Error Handling

ZodError Structure

try {
  User.parse(invalidData);
} catch (error) {
  if (error instanceof z.ZodError) {
    console.log(error.issues);
    // [
    //   {
    //     code: "invalid_type",
    //     expected: "string",
    //     received: "number",
    //     path: ["email"],
    //     message: "Expected string, received number"
    //   },
    //   {
    //     code: "too_small",
    //     minimum: 8,
    //     type: "string",
    //     inclusive: true,
    //     path: ["password"],
    //     message: "String must contain at least 8 character(s)"
    //   }
    // ]
  }
}

Custom Error Messages

const user = z.object({
  email: z.string().email("Please enter a valid email address"),
  age: z.number()
    .int("Age must be an integer")
    .min(0, "Age must be 0 or greater")
    .max(120, "Age must be 120 or less"),
});

Error Formatting

const result = User.safeParse(invalidData);

if (!result.success) {
  // Flat errors (by field)
  const flatErrors = result.error.flatten();
  console.log(flatErrors.fieldErrors);
  // {
  //   email: ["Invalid email"],
  //   age: ["Number must be greater than 0"]
  // }

  // Form errors (React Hook Form format)
  const formErrors = result.error.format();
  console.log(formErrors.email?._errors); // ["Invalid email"]
}

Performance Optimization

Schema Reuse

// ❌ Create new each time (slow)
function validate(data: unknown) {
  const schema = z.object({
    name: z.string(),
    age: z.number(),
  });
  return schema.parse(data);
}

// ✅ Reuse schema (fast)
const schema = z.object({
  name: z.string(),
  age: z.number(),
});

function validate(data: unknown) {
  return schema.parse(data);
}

Partial Validation

// Partial validation when full validation is not needed
const user = z.object({
  id: z.number(),
  email: z.string().email(),
  profile: z.object({
    bio: z.string(),
    website: z.string().url(),
  }),
});

// Validate only email
const emailOnly = user.pick({ email: true });
emailOnly.parse({ email: "john@test.com" }); // ✅ Fast

// Validate only profile
const profileOnly = user.pick({ profile: true });

Next Steps