Zod From Scratch: Validate, Infer, and Use Schemas in a Real Project.
Table of Contents
- Introduction
- Test Suite
- The Full Pattern as a Template
- Test Cases
- Key Methods
- Complete Test File
- Definition of Schema
- Zod API Reference
- Type Inference
- Using the Zod Schema
Introduction
When building a member dashboard, one of the most common operations is letting users update their own profile. But before any data reaches your database, it needs to be validated :
- is the URL actually a URL?
- Is the name non-empty?
- Are the skills really an array of strings?
This is exactly what Zod solves. Zod is a TypeScript-first schema validation library that lets you define the shape and rules of your data in one place, then reuse that definition for both runtime validation and static type inference.
In this section, we build MemberUpdateSchema — the schema responsible for validating partial profile update payloads sent from the dashboard.
We follow a Test Driven Development approach:
- write failing tests first, then write the schema to make them pass, and
- finally wire everything up inside a Next.js server action.
By the end, you will have a schema that validates, a type that is inferred automatically, and a server action that uses both.
Purpose: Partial update payload for the dashboard profile.
- Test Suite
- Definition of Schema
- Type Inference
- Using the Zod Schema
Test Suite
Following Test Driven Development, we start by writing the test cases for each field. From there, we follow the Red → Green → Refactor cycle.
The Full Pattern as a Template
Every test in your file follows this exact structure:
it('what this test verifies', () => { // Step 1: Run safeParse with test input const result = MemberUpdateSchema.safeParse({ someField: 'value' }); // Step 2: Check if it passed or failed expect(result.success).toBe(true); // or false for rejection tests // Step 3: Narrow the type and check the data if (result.success) { expect(result.data.someField).toBe('value'); } });
Test Cases
-
fullName — 'valid partial update with fullName only'
it('valid partial update with fullName only', () => { const result = MemberUpdateSchema.safeParse({ fullName: 'New Name' }); expect(result.success).toBe(true); if (result.success) { expect(result.data.fullName).toBe('New Name'); } }); -
phone — 'valid partial update with phone only'
it('valid partial update with phone only', () => { const result = MemberUpdateSchema.safeParse({ phone: '+221771234567' }); expect(result.success).toBe(true); if (result.success) { expect(result.data.phone).toBe('+221771234567'); } }); -
city — 'valid partial update with city only'
it('valid partial update with city only', () => { const result = MemberUpdateSchema.safeParse({ city: 'Dakar' }); expect(result.success).toBe(true); if (result.success) { expect(result.data.city).toBe('Dakar'); } }); -
professionalLink — 'invalid professionalLink fails with error on professionalLink'
it('invalid professionalLink fails with error on professionalLink', () => { const result = MemberUpdateSchema.safeParse({ professionalLink: 'not-a-url' }); expect(result.success).toBe(false); if (!result.success) { expect(result.error.flatten().fieldErrors.professionalLink).toBeDefined(); } }); -
technicalSkills — 'valid technicalSkills with string array'
it('valid technicalSkills with string array', () => { const result = MemberUpdateSchema.safeParse({ technicalSkills: ['React', 'TypeScript', 'Node.js'] }); expect(result.success).toBe(true); if (result.success) { expect(result.data.technicalSkills).toEqual(['React', 'TypeScript', 'Node.js']); } });
Key Methods
safeParse returns a discriminated union — one of two possible shapes depending on whether validation succeeded or failed:
-
On success:
{ "success": true, "data": "T // the parsed & typed value (e.g. MemberUpdateData)"} -
On failure:
{ "success": false, "error": "ZodError // contains all validation issues"}
expect(value) — Wraps a value so you can chain matchers against it. It is the entry point for every assertion in Vitest. Example: expect(result.success) prepares the value result.success to be tested.
.toBe(value) — Checks for strict equality (===). Use it for primitives like strings, numbers, and booleans. Example: expect(result.success).toBe(true) asserts that result.success is exactly true.
.toBeDefined() — Asserts that the value is not undefined. Useful for checking that an error field or property actually exists. Example: expect(result.error.flatten().fieldErrors.professionalLink).toBeDefined() confirms that a validation error was attached to that field.
.toEqual(value) — Performs a deep equality check. Unlike toBe, it compares the contents of objects and arrays recursively rather than their reference. Example: expect(result.data.technicalSkills).toEqual(['React', 'TypeScript', 'Node.js']) confirms that the array contains exactly those elements in that order.
Complete Test File — member-update.test.ts
import { describe, it, expect } from 'vitest'; import { MemberUpdateSchema } from './member-update'; describe('MemberUpdateSchema', () => { it('valid partial update with fullName only', () => { const result = MemberUpdateSchema.safeParse({ fullName: 'New Name' }); expect(result.success).toBe(true); if (result.success) { expect(result.data.fullName).toBe('New Name'); } }); it('valid partial update with phone only', () => { const result = MemberUpdateSchema.safeParse({ phone: '+221771234567' }); expect(result.success).toBe(true); if (result.success) { expect(result.data.phone).toBe('+221771234567'); } }); it('valid partial update with city only', () => { const result = MemberUpdateSchema.safeParse({ city: 'Dakar' }); expect(result.success).toBe(true); if (result.success) { expect(result.data.city).toBe('Dakar'); } }); it('invalid professionalLink fails with error on professionalLink', () => { const result = MemberUpdateSchema.safeParse({ professionalLink: 'not-a-url' }); expect(result.success).toBe(false); if (!result.success) { expect(result.error.flatten().fieldErrors.professionalLink).toBeDefined(); } }); it('valid technicalSkills with string array', () => { const result = MemberUpdateSchema.safeParse({ technicalSkills: ['React', 'TypeScript', 'Node.js'] }); expect(result.success).toBe(true); if (result.success) { expect(result.data.technicalSkills).toEqual(['React', 'TypeScript', 'Node.js']); } }); });
Definition of Schema
Once the tests are red, we write the corresponding code to turn them green.
// src/lib/schemas/member-update.ts import { z } from 'zod'; export const MemberUpdateSchema = z.object({ fullName: z.string().min(1, 'Le nom est requis').optional(), phone: z.string().optional(), city: z.string().optional(), professionalLink: z.string().url('URL invalide').optional().or(z.literal('')), technicalSkills: z.array(z.string()).optional(), });
Zod API Reference
| Method | Description |
|---|---|
| z.object({ ... }) | Creates a schema for a JavaScript object with known keys. Each key gets its own validation rule. |
| z.string() | Validates that the value is a string. |
| .min(1, 'message') | Adds a minimum length constraint. Fails if the string is empty. |
| .optional() | Makes the field not required — the value can be undefined or simply absent from the object. |
| .url('message') | A string format validator that checks the string is a valid URL. |
| .email() | A string format validator that checks the string is a valid email address. |
| .or(z.literal('')) | Creates a union: the value must match either the preceding schema or an empty string literal. |
| z.array(z.string()) | Validates an array where every element must be a string. |
Type Inference
After defining the schema, we infer the TypeScript type from it. z.infer is a TypeScript utility that extracts the static type directly from a Zod schema, so your types and your validation rules never drift out of sync.
export type MemberUpdateData = z.infer<typeof MemberUpdateSchema>; // Equivalent to writing: // type MemberUpdateData = { // fullName?: string | undefined // phone?: string | undefined // city?: string | undefined // professionalLink?: string | "" | undefined // technicalSkills?: string[] | undefined // }
Using the Zod Schema
src/app/dashboard/(authenticated)/profil/actions.ts
This is the server action that validates and persists profile updates. Both exports from member-update.ts are used here:
- MemberUpdateSchema — called via .safeParse(rawFormData) to validate the incoming payload.
- MemberUpdateData — used as the type annotation for the parsed result.
// actions.ts export async function updateMemberProfile( rawFormData: unknown ): Promise<{ success: boolean; error?: string }> { const cookieStore = await cookies(); // ... auth checks ... const parsed = MemberUpdateSchema.safeParse(rawFormData); if (!parsed.success) { const fieldErrors = parsed.error.flatten().fieldErrors; return { success: false, error: 'Données invalides: ' + JSON.stringify(fieldErrors) }; } const formData: MemberUpdateData = parsed.data; const dbData: Record<string, unknown> = {}; if (formData.fullName !== undefined) dbData.full_name = formData.fullName; if (formData.phone !== undefined) dbData.phone = formData.phone; // ... maps each camelCase field to its snake_case DB column ... const { error } = await supabase .from('members') .update(dbData) .eq('id', user.id); // ... error handling, revalidation ... }
Thanks for Reading
Alain Ngongang