Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds
Powrót do kolekcji
Przewodnik12 min czytania

Zod

Zod to biblioteka walidacji schematów TypeScript-first z automatyczną inferencją typów. Poznaj schematy, transformacje, integrację z React Hook Form i Server Actions.

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 zod

Podstawowe 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 zod
Code
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

AspektZodYupJoi
TypeScriptFirst-classGoodPoor
Type inferenceAutomatycznaWymaga typuNie
Bundle size~12kb~15kb~30kb
PerformanceBardzo dobraDobraŚrednia
APIFunkcyjneChainableChainable
Async validationTakTakTak

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 } // ❌ Duplikacja

FAQ

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