Zod - Kompletny przewodnik po walidacji TypeScript-first
Czym jest Zod i dlaczego zrewolucjonizował walidację?
Zod to biblioteka do walidacji danych stworzona specjalnie dla TypeScript. W przeciwieństwie do tradycyjnych bibliotek walidacji (jak Joi czy Yup), Zod oferuje pełną inferencję typów - definiujesz schemat raz, a TypeScript automatycznie wyznacza odpowiedni typ.
To oznacza koniec z duplikacją: nie musisz już pisać interfejsu TypeScript osobno od schematu walidacji. Zod robi jedno i drugie.
Dlaczego Zod?
TypeScript-first
Code
TypeScript
import { z } from 'zod'
// Definiujesz schemat RAZ
const UserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().min(18).optional(),
})
// TypeScript automatycznie wyznacza typ
type User = z.infer<typeof UserSchema>
// {
// name: string
// email: string
// age?: number | undefined
// }
// Nie musisz pisać tego osobno!Zero zależności
Zod nie ma żadnych zależności runtime. Paczka waży ~50kb (minified), ~12kb (gzipped).
Działa wszędzie
- Node.js
- Przeglądarki
- React Native
- Edge runtimes (Vercel, Cloudflare)
Instalacja
Code
Bash
npm install zod
# Lub z yarn/pnpm
yarn add zod
pnpm add zodPodstawowe schematy
Typy prymitywne
Code
TypeScript
import { z } from 'zod'
// String
const stringSchema = z.string()
// Number
const numberSchema = z.number()
// Boolean
const booleanSchema = z.boolean()
// Date
const dateSchema = z.date()
// BigInt
const bigintSchema = z.bigint()
// Symbol
const symbolSchema = z.symbol()
// Undefined
const undefinedSchema = z.undefined()
// Null
const nullSchema = z.null()
// Void (undefined lub null)
const voidSchema = z.void()
// Any (unikaj jeśli możliwe)
const anySchema = z.any()
// Unknown (bezpieczniejszy niż any)
const unknownSchema = z.unknown()
// Never
const neverSchema = z.never()Walidacje string
Code
TypeScript
const stringSchema = z.string()
.min(1, 'Wymagane')
.max(100, 'Maksymalnie 100 znaków')
.email('Niepoprawny email')
.url('Niepoprawny URL')
.uuid('Niepoprawny UUID')
.cuid()
.cuid2()
.ulid()
.regex(/^[a-z]+$/, 'Tylko małe litery')
.includes('hello')
.startsWith('https://')
.endsWith('.com')
.datetime() // ISO 8601
.ip() // IPv4 lub IPv6
.trim() // Trimuje whitespace przed walidacją
.toLowerCase()
.toUpperCase()
// Przykłady użycia
const EmailSchema = z.string().email()
const PasswordSchema = z.string()
.min(8, 'Minimum 8 znaków')
.regex(/[A-Z]/, 'Wymaga wielkiej litery')
.regex(/[0-9]/, 'Wymaga cyfry')
.regex(/[^A-Za-z0-9]/, 'Wymaga znaku specjalnego')Walidacje number
Code
TypeScript
const numberSchema = z.number()
.gt(5) // > 5
.gte(5) // >= 5
.lt(10) // < 10
.lte(10) // <= 10
.int() // tylko integers
.positive() // > 0
.nonnegative() // >= 0
.negative() // < 0
.nonpositive() // <= 0
.multipleOf(5) // 5, 10, 15...
.finite() // nie Infinity
.safe() // Number.MIN_SAFE_INTEGER - MAX_SAFE_INTEGER
// Przykłady
const AgeSchema = z.number().int().min(0).max(150)
const PriceSchema = z.number().positive().multipleOf(0.01)Obiekty
Code
TypeScript
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2),
email: z.string().email(),
age: z.number().int().positive().optional(),
role: z.enum(['admin', 'user', 'guest']),
metadata: z.record(z.string()), // { [key: string]: string }
})
type User = z.infer<typeof UserSchema>
// Partial - wszystkie pola opcjonalne
const PartialUserSchema = UserSchema.partial()
// Required - wszystkie pola wymagane
const RequiredUserSchema = UserSchema.required()
// Pick - wybierz tylko niektóre pola
const UserNameSchema = UserSchema.pick({ name: true, email: true })
// Omit - pomiń niektóre pola
const UserWithoutIdSchema = UserSchema.omit({ id: true })
// Extend - rozszerz schemat
const AdminSchema = UserSchema.extend({
permissions: z.array(z.string()),
department: z.string(),
})
// Merge - połącz dwa schematy
const CombinedSchema = UserSchema.merge(z.object({
address: z.string(),
}))
// passthrough - pozwól na dodatkowe pola
const FlexibleSchema = UserSchema.passthrough()
// strict - błąd dla dodatkowych pól
const StrictSchema = UserSchema.strict()
// strip - usuń dodatkowe pola (domyślne)
const StrippedSchema = UserSchema.strip()Tablice
Code
TypeScript
const StringArraySchema = z.array(z.string())
const NumberArraySchema = z.array(z.number())
.min(1, 'Minimum 1 element')
.max(10, 'Maksimum 10 elementów')
.length(5, 'Dokładnie 5 elementów')
.nonempty('Tablica nie może być pusta')
// Tuple - tablica o stałej długości i typach
const CoordinatesSchema = z.tuple([z.number(), z.number()])
type Coordinates = z.infer<typeof CoordinatesSchema> // [number, number]
// Tuple z rest
const ArgsSchema = z.tuple([z.string(), z.number()]).rest(z.boolean())
// [string, number, ...boolean[]]Unie i literały
Code
TypeScript
// Literal
const StatusSchema = z.literal('active')
type Status = z.infer<typeof StatusSchema> // 'active'
// Enum (z.enum)
const RoleSchema = z.enum(['admin', 'user', 'guest'])
type Role = z.infer<typeof RoleSchema> // 'admin' | 'user' | 'guest'
// Dostęp do wartości enum
RoleSchema.options // ['admin', 'user', 'guest']
RoleSchema.enum // { admin: 'admin', user: 'user', guest: 'guest' }
// Native enum
enum NativeRole {
Admin = 'admin',
User = 'user',
}
const NativeRoleSchema = z.nativeEnum(NativeRole)
// Union
const StringOrNumberSchema = z.union([z.string(), z.number()])
// lub krócej:
const StringOrNumber = z.string().or(z.number())
// Discriminated union (lepszy performance)
const EventSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
z.object({ type: z.literal('keypress'), key: z.string() }),
z.object({ type: z.literal('scroll'), direction: z.enum(['up', 'down']) }),
])
type Event = z.infer<typeof EventSchema>
// | { type: 'click', x: number, y: number }
// | { type: 'keypress', key: string }
// | { type: 'scroll', direction: 'up' | 'down' }Nullable i Optional
Code
TypeScript
// Optional - może być undefined
const OptionalSchema = z.string().optional()
type Optional = z.infer<typeof OptionalSchema> // string | undefined
// Nullable - może być null
const NullableSchema = z.string().nullable()
type Nullable = z.infer<typeof NullableSchema> // string | null
// Nullish - może być null lub undefined
const NullishSchema = z.string().nullish()
type Nullish = z.infer<typeof NullishSchema> // string | null | undefined
// Default - wartość domyślna jeśli undefined
const DefaultSchema = z.string().default('default value')
const WithDefault = z.object({
name: z.string(),
role: z.string().default('user'),
})
// Catch - wartość jeśli walidacja się nie powiedzie
const CatchSchema = z.string().catch('fallback')Walidacja i parsowanie
safeParse vs parse
Code
TypeScript
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
})
// parse - rzuca błąd jeśli niepoprawne
try {
const user = UserSchema.parse({
name: 'Jan',
email: 'invalid-email',
})
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error.issues)
}
}
// safeParse - zwraca result object (zalecane!)
const result = UserSchema.safeParse({
name: 'Jan',
email: 'jan@example.com',
})
if (result.success) {
console.log(result.data) // typowane!
// { name: string, email: string }
} else {
console.log(result.error.issues)
// [{ code: 'invalid_string', message: 'Invalid email', path: ['email'] }]
}
// parseAsync - dla schematów z async refinements
const asyncResult = await UserSchema.safeParseAsync(data)Obsługa błędów
Code
TypeScript
const result = UserSchema.safeParse(invalidData)
if (!result.success) {
// Surowe issues
console.log(result.error.issues)
// Sformatowane błędy
const formatted = result.error.format()
// {
// name: { _errors: ['Required'] },
// email: { _errors: ['Invalid email'] }
// }
// Spłaszczone błędy (świetne dla formularzy)
const flattened = result.error.flatten()
// {
// formErrors: [],
// fieldErrors: {
// name: ['Required'],
// email: ['Invalid email']
// }
// }
}Transformacje
Code
TypeScript
// Transform - przekształć wartość po walidacji
const StringToNumberSchema = z.string().transform((val) => parseInt(val, 10))
type StringToNumber = z.infer<typeof StringToNumberSchema> // number
// Transform z walidacją
const PositiveIntSchema = z.string()
.transform((val) => parseInt(val, 10))
.pipe(z.number().positive().int())
// Coerce - automatyczna konwersja typów
const CoercedStringSchema = z.coerce.string() // any → string
const CoercedNumberSchema = z.coerce.number() // "42" → 42
const CoercedBooleanSchema = z.coerce.boolean() // "true" → true
const CoercedDateSchema = z.coerce.date() // "2024-01-01" → Date
// Preprocess - transformacja PRZED walidacją
const TrimmedStringSchema = z.preprocess(
(val) => (typeof val === 'string' ? val.trim() : val),
z.string().min(1)
)
// Praktyczny przykład: API input
const ApiInputSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(10),
search: z.string().trim().optional(),
sortBy: z.enum(['name', 'date', 'price']).default('date'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
})Refinements
Code
TypeScript
// Refine - custom walidacja
const PasswordSchema = z.string()
.min(8)
.refine((val) => /[A-Z]/.test(val), {
message: 'Wymaga wielkiej litery',
})
.refine((val) => /[0-9]/.test(val), {
message: 'Wymaga cyfry',
})
// Superrefine - pełna kontrola nad błędami
const FormSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Hasła muszą być identyczne',
path: ['confirmPassword'],
})
}
})
// Async refine (np. sprawdzenie czy email istnieje w bazie)
const UniqueEmailSchema = z.string().email().refine(
async (email) => {
const exists = await checkEmailExists(email)
return !exists
},
{ message: 'Email już istnieje' }
)Integracja z React Hook Form
Code
Bash
npm install react-hook-form @hookform/resolvers zodCode
TypeScript
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// Schema walidacji
const RegisterSchema = z.object({
name: z.string().min(2, 'Minimum 2 znaki'),
email: z.string().email('Niepoprawny email'),
password: z.string()
.min(8, 'Minimum 8 znaków')
.regex(/[A-Z]/, 'Wymaga wielkiej litery')
.regex(/[0-9]/, 'Wymaga cyfry'),
confirmPassword: z.string(),
terms: z.boolean().refine((val) => val === true, {
message: 'Musisz zaakceptować regulamin',
}),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Hasła muszą być identyczne',
path: ['confirmPassword'],
})
type RegisterForm = z.infer<typeof RegisterSchema>
function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterForm>({
resolver: zodResolver(RegisterSchema),
defaultValues: {
name: '',
email: '',
password: '',
confirmPassword: '',
terms: false,
},
})
const onSubmit = async (data: RegisterForm) => {
console.log('Valid data:', data)
await registerUser(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">Imię</label>
<input id="name" {...register('name')} />
{errors.name && <span className="error">{errors.name.message}</span>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register('email')} />
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
<div>
<label htmlFor="password">Hasło</label>
<input id="password" type="password" {...register('password')} />
{errors.password && <span className="error">{errors.password.message}</span>}
</div>
<div>
<label htmlFor="confirmPassword">Potwierdź hasło</label>
<input id="confirmPassword" type="password" {...register('confirmPassword')} />
{errors.confirmPassword && (
<span className="error">{errors.confirmPassword.message}</span>
)}
</div>
<div>
<label>
<input type="checkbox" {...register('terms')} />
Akceptuję regulamin
</label>
{errors.terms && <span className="error">{errors.terms.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Rejestracja...' : 'Zarejestruj'}
</button>
</form>
)
}Server Actions (Next.js)
TSapp/actions/posts.ts
TypeScript
// app/actions/posts.ts
'use server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
const CreatePostSchema = z.object({
title: z.string().min(1, 'Tytuł jest wymagany').max(100),
content: z.string().min(10, 'Treść musi mieć minimum 10 znaków'),
tags: z.array(z.string()).min(1, 'Dodaj przynajmniej jeden tag').max(5),
published: z.boolean().default(false),
})
export type CreatePostInput = z.infer<typeof CreatePostSchema>
export type ActionResult = {
success: boolean
errors?: Record<string, string[]>
data?: any
}
export async function createPost(formData: FormData): Promise<ActionResult> {
// Wyciągnij dane z FormData
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
tags: formData.getAll('tags'),
published: formData.get('published') === 'true',
}
// Waliduj
const result = CreatePostSchema.safeParse(rawData)
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
}
}
// Zapisz do bazy
try {
const post = await db.posts.create({
data: result.data,
})
revalidatePath('/posts')
return {
success: true,
data: post,
}
} catch (error) {
return {
success: false,
errors: { _form: ['Błąd zapisu do bazy'] },
}
}
}TSapp/posts/new/page.tsx
TypeScript
// app/posts/new/page.tsx
'use client'
import { useFormState } from 'react-dom'
import { createPost, type ActionResult } from '@/app/actions/posts'
const initialState: ActionResult = { success: false }
export default function NewPostPage() {
const [state, formAction] = useFormState(createPost, initialState)
return (
<form action={formAction}>
<div>
<label htmlFor="title">Tytuł</label>
<input id="title" name="title" required />
{state.errors?.title && (
<span className="error">{state.errors.title[0]}</span>
)}
</div>
<div>
<label htmlFor="content">Treść</label>
<textarea id="content" name="content" required />
{state.errors?.content && (
<span className="error">{state.errors.content[0]}</span>
)}
</div>
<button type="submit">Opublikuj</button>
{state.success && <p className="success">Post został utworzony!</p>}
</form>
)
}API Validation
TSapp/api/users/route.ts
TypeScript
// app/api/users/route.ts
import { z } from 'zod'
import { NextResponse } from 'next/server'
const CreateUserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
role: z.enum(['admin', 'user']).default('user'),
})
export async function POST(request: Request) {
try {
const body = await request.json()
const result = CreateUserSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
},
{ status: 400 }
)
}
const user = await db.users.create({
data: result.data,
})
return NextResponse.json(user, { status: 201 })
} catch (error) {
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
// Walidacja query params
const QuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(10),
search: z.string().optional(),
})
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const query = QuerySchema.safeParse({
page: searchParams.get('page'),
limit: searchParams.get('limit'),
search: searchParams.get('search'),
})
if (!query.success) {
return NextResponse.json(
{ error: 'Invalid query parameters' },
{ status: 400 }
)
}
const { page, limit, search } = query.data
const users = await db.users.findMany({
where: search ? { name: { contains: search } } : undefined,
skip: (page - 1) * limit,
take: limit,
})
return NextResponse.json({ users, page, limit })
}Zod vs Alternatywy
| Aspekt | Zod | Yup | Joi |
|---|---|---|---|
| TypeScript | First-class | Good | Poor |
| Type inference | Automatyczna | Wymaga typu | Nie |
| Bundle size | ~12kb | ~15kb | ~30kb |
| Performance | Bardzo dobra | Dobra | Średnia |
| API | Funkcyjne | Chainable | Chainable |
| Async validation | Tak | Tak | Tak |
Best Practices
Do's
Code
TypeScript
// ✅ Używaj safeParse zamiast parse
const result = schema.safeParse(data)
// ✅ Definiuj schematy poza komponentami
const UserSchema = z.object({ ... })
// ✅ Używaj coerce dla danych z formularzy/URL
z.coerce.number()
// ✅ Wyciągaj typy ze schematów
type User = z.infer<typeof UserSchema>
// ✅ Używaj error messages po polsku
z.string().min(1, 'Pole wymagane')Don'ts
Code
TypeScript
// ❌ Nie definiuj schematów w renderze
function Component() {
const Schema = z.object({}) // ❌ Tworzy się przy każdym renderze
}
// ❌ Nie używaj parse bez try-catch
const data = Schema.parse(input) // Może rzucić!
// ❌ Nie ignoruj typu infer
const Schema = z.object({ name: z.string() })
interface User { name: string } // ❌ DuplikacjaFAQ
Czy Zod działa z JavaScript (bez TypeScript)?
Tak, ale tracisz główną zaletę - automatyczną inferencję typów.
Jak walidować pliki?
Code
TypeScript
const FileSchema = z.instanceof(File).refine(
(file) => file.size <= 5 * 1024 * 1024,
'Maksymalny rozmiar: 5MB'
)Jak robić conditional validation?
Code
TypeScript
const Schema = z.object({
type: z.enum(['email', 'phone']),
value: z.string(),
}).refine(
(data) => {
if (data.type === 'email') {
return z.string().email().safeParse(data.value).success
}
return z.string().regex(/^\d{9}$/).safeParse(data.value).success
},
{ message: 'Niepoprawny format', path: ['value'] }
)Podsumowanie
Zod to must-have dla projektów TypeScript:
- Type inference - Jeden schemat, automatyczny typ
- Zero dependencies - Mały bundle
- Świetne API - Czytelne i funkcyjne
- Integracje - React Hook Form, tRPC, Next.js