We use cookies to enhance your experience on the site
CodeWorlds

Form Validation with Zod

In Quantum Metropolis, every system requires precise data validation — from access terminals to implant control panels. Zod is a validation library designed with TypeScript in mind that has become the standard in the Next.js ecosystem. Unlike other solutions, Zod automatically generates TypeScript types from validation schemas, eliminating code duplication.

Why Zod?

In the year 2150, Quantum Metropolis systems require reliable data validation. Zod stands out from other validation libraries (Yup, Joi) for several reasons:

  1. TypeScript-first — Zod schemas automatically generate types, eliminating duplication
  2. Zero dependencies — lightweight library (~10 KB gzipped)
  3. Next.js standard — officially recommended in Next.js documentation
  4. Works on server and client — ideal for Server Actions

Installation and Import

1npm install zod
1import { z } from 'zod';

Basic Zod Schemas

Zod offers a set of methods for defining data types — like digital DNA describing the structure of information in the system:

1import { z } from 'zod';
2
3// Primitive types
4const nameSchema = z.string();
5const ageSchema = z.number();
6const isActiveSchema = z.boolean();
7
8// Object schema — we combine types into a structure
9const userSchema = z.object({
10  name: z.string(),
11  email: z.string().email(),
12  age: z.number().int().positive(),
13  isActive: z.boolean(),
14});

Each

z.string()
,
z.number()
,
z.boolean()
is a basic building block of a schema. The
z.object()
method combines them into a comprehensive structure — like a terminal schema in Quantum Metropolis.

Validation Methods: parse and safeParse

Zod offers two ways to validate data:

1import { z } from 'zod';
2
3const schema = z.object({
4  name: z.string().min(2),
5  email: z.string().email(),
6});
7
8// 1. parse() — throws an exception for invalid data
9try {
10  const data = schema.parse({ name: "Neo", email: "neo@quantum.io" });
11  console.log("Valid data:", data);
12} catch (error) {
13  console.error("Validation error:", error);
14}
15
16// 2. safeParse() — does NOT throw, returns a result object
17const result = schema.safeParse({ name: "", email: "bad-email" });
18
19if (result.success) {
20  console.log("Data:", result.data);
21} else {
22  console.log("Errors:", result.error.errors);
23  // [{ path: ["name"], message: "String must contain at least 2 character(s)" }, ...]
24}

Rule: Use

safeParse()
in forms and Server Actions (safer), and
parse()
when you want to fail fast on invalid data.

Error Messages and Validation Rules

Zod allows you to precisely define validation rules with custom messages — like configuring a security system on a terminal:

1import { z } from 'zod';
2
3const registrationSchema = z.object({
4  username: z.string()
5    .min(3, { message: "Username must be at least 3 characters" })
6    .max(20, { message: "Username can be at most 20 characters" }),
7  email: z.string()
8    .email({ message: "Invalid email address format" }),
9  age: z.number()
10    .int({ message: "Age must be a whole number" })
11    .min(13, { message: "You must be at least 13 years old" }),
12  password: z.string()
13    .min(8, { message: "Password must be at least 8 characters" })
14    .regex(/[A-Z]/, { message: "Password must contain an uppercase letter" })
15    .regex(/[0-9]/, { message: "Password must contain a digit" }),
16  acceptTerms: z.boolean()
17    .refine(val => val === true, {
18      message: "You must accept the system terms of use"
19    }),
20});

The

.refine()
method allows you to define custom, non-standard validation rules — useful when built-in methods aren't enough.

Type Inference: z.infer

One of Zod's most powerful features is the automatic generation of TypeScript types from schemas. You don't need to maintain separate interfaces:

1import { z } from 'zod';
2
3const implantSchema = z.object({
4  id: z.string().uuid(),
5  name: z.string().min(1),
6  powerLevel: z.number().min(0).max(100),
7  isActive: z.boolean(),
8  tags: z.array(z.string()),
9});
10
11// Type automatically generated from schema
12type Implant = z.infer<typeof implantSchema>;
13// Manual equivalent: { id: string; name: string; powerLevel: number; isActive: boolean; tags: string[] }
14
15// Now type and validation are SYNCHRONIZED
16function processImplant(data: Implant) {
17  console.log(data.name); // TypeScript knows the type!
18}

Thanks to

z.infer
, you have a single source of truth — the schema defines both validation and the TypeScript type.

Integration with React Hook Form

Zod works great with React Hook Form thanks to the

zodResolver
adapter. This is the most commonly encountered pattern in Next.js applications:

1import { useForm } from 'react-hook-form';
2import { zodResolver } from '@hookform/resolvers/zod';
3import { z } from 'zod';
4
5// 1. Define the schema
6const loginSchema = z.object({
7  email: z.string()
8    .email({ message: "Invalid email address" }),
9  password: z.string()
10    .min(8, { message: "Password must be at least 8 characters" }),
11});
12
13// 2. Generate the type
14type LoginForm = z.infer<typeof loginSchema>;
15
16// 3. Use in the component
17export default function LoginPage() {
18  const {
19    register,
20    handleSubmit,
21    formState: { errors, isSubmitting }
22  } = useForm<LoginForm>({
23    resolver: zodResolver(loginSchema), // Zod as the validator
24    defaultValues: { email: '', password: '' }
25  });
26
27  const onSubmit = async (data: LoginForm) => {
28    // data is already validated and typed!
29    console.log("Logging in:", data);
30  };
31
32  return (
33    <form onSubmit={handleSubmit(onSubmit)}>
34      <input {...register('email')} placeholder="Email" />
35      {errors.email && <p>{errors.email.message}</p>}
36
37      <input {...register('password')} type="password" placeholder="Password" />
38      {errors.password && <p>{errors.password.message}</p>}
39
40      <button type="submit" disabled={isSubmitting}>
41        {isSubmitting ? 'Logging in...' : 'Log in'}
42      </button>
43    </form>
44  );
45}

The key line is

resolver: zodResolver(loginSchema)
— it connects the Zod schema with the React Hook Form validation system.

Reusable Schemas and Composition

In large applications, it's worth creating reusable schemas that can be combined and extended:

1import { z } from 'zod';
2
3// Base address schema — used in multiple places
4const addressSchema = z.object({
5  street: z.string().min(1, "Street is required"),
6  city: z.string().min(1, "City is required"),
7  postCode: z.string().regex(/^d{2}-d{3}$/, "Format: XX-XXX"),
8});
9
10// User schema — extending with address
11const userSchema = z.object({
12  name: z.string().min(2),
13  email: z.string().email(),
14  address: addressSchema,                    // Nesting
15  shippingAddress: addressSchema.optional(),  // Optional
16});
17
18// .merge() — merging two schemas
19const baseProfile = z.object({ name: z.string(), bio: z.string() });
20const socialProfile = z.object({ twitter: z.string(), github: z.string() });
21const fullProfile = baseProfile.merge(socialProfile);
22
23// .extend() — adding fields to an existing schema
24const adminUser = userSchema.extend({
25  role: z.literal('admin'),
26  permissions: z.array(z.string()),
27});
28
29type AdminUser = z.infer<typeof adminUser>;

Advanced Techniques: refine and superRefine

The

.refine()
method allows validation that can't be expressed declaratively — e.g. comparing two fields:

1import { z } from 'zod';
2
3const passwordSchema = z.object({
4  password: z.string().min(8),
5  confirmPassword: z.string(),
6}).refine(data => data.password === data.confirmPassword, {
7  message: "Passwords do not match",
8  path: ["confirmPassword"], // Error assigned to confirmPassword field
9});
10
11// superRefine — when you need multiple errors simultaneously
12const advancedSchema = z.object({
13  accountType: z.enum(['personal', 'business']),
14  personalId: z.string().optional(),
15  companyId: z.string().optional(),
16}).superRefine((data, ctx) => {
17  if (data.accountType === 'personal' && !data.personalId) {
18    ctx.addIssue({
19      code: z.ZodIssueCode.custom,
20      message: "Personal ID is required for a personal account",
21      path: ['personalId'],
22    });
23  }
24  if (data.accountType === 'business' && !data.companyId) {
25    ctx.addIssue({
26      code: z.ZodIssueCode.custom,
27      message: "Company ID is required for a business account",
28      path: ['companyId'],
29    });
30  }
31});

Alternatives: Yup and Joi

Zod is the standard in the Next.js ecosystem, but alternatives exist:

  • Yup — popular with Formik, declarative syntax, but requires manual TypeScript type definitions
  • Joi — powerful, feature-rich, but large (~40 KB) and mostly server-side

Zod is recommended for Next.js projects because it combines validation with TypeScript types and works on both server and client.

Summary

Zod is an essential tool in the Next.js developer's arsenal:

  • z.string()
    ,
    z.number()
    ,
    z.object()
    — defining schemas
  • parse()
    and
    safeParse()
    — data validation
  • .min()
    ,
    .email()
    ,
    .regex()
    — rules with error messages
  • .refine()
    — custom validation
  • zodResolver
    — integration with React Hook Form
  • z.infer<typeof schema>
    — automatic type generation
Go to CodeWorlds