Zod vs Joi: walidacja danych w TypeScript bez frustracji

0
40
1/5 - (2 votes)

Nawigacja:

Po co w ogóle walidować dane w TypeScript, skoro są typy?

Typy TypeScript działają tylko w czasie kompilacji

TypeScript daje komfort pracy z typami, ale działa wyłącznie na etapie kompilacji i w edytorze. Wygenerowany kod to dalej czysty JavaScript, który w runtime nie ma już informacji o typach. Funkcja może oczekiwać argumentu typu User, ale w rzeczywistości dostać cokolwiek – pusty obiekt, string, a nawet undefined.

Gdzie najczęściej się to łamie? W punktach styku ze światem zewnętrznym:

  • requesty HTTP (Express, Next.js, NestJS),
  • formularze z frontendu,
  • zewnętrzne API (REST, GraphQL),
  • dane z bazy, której schemat nie jest pilnowany przez aplikację (np. luźny MongoDB),
  • pliki konfiguracyjne (JSON, .env).

TypeScript „wierzy” w typy, które sam mu zadeklarujesz. Nie weryfikuje, czy to, co przychodzi w req.body, rzeczywiście pasuje do typu CreateUserDto. Do tego potrzebna jest weryfikacja runtime, a więc walidacja danych.

Kontrakt dla programisty vs dane z zewnątrz

Typy TS opisują kontrakt między programistami. Ustalacie: funkcja createUser przyjmuje obiekt CreateUserInput i zwraca User. IDE pilnuje, żeby nikt w kodzie nie przekazał przypadkowego stringa zamiast obiektu. Ale jeśli dane pochodzą z cudzego frontu, mobilki lub zewnętrznego integratora, kontrakt może być łamany bez Twojej wiedzy.

Przykład? Deklarujesz:

type CreateUserInput = {
  email: string;
  password: string;
};

function createUser(input: CreateUserInput) {
  // ...
}

A realny request wygląda tak:

POST /users
{
  "email": "to-nie-jest-email",
  "password": 12345,
  "role": "admin"
}

TypeScript nie widzi tego requestu. Dla niego istnieje tylko Twój typ. Jeśli bez walidacji zrobisz createUser(req.body), to w chwili wywołania funkcji nic nie chroni Cię przed nieprawidłowymi wartościami. Dopiero kiedy w kodzie spróbujesz użyć np. input.password.trim(), pojawi się runtime error.

Śmieciowe dane z requestu – krótki, konkretny przykład

Załóżmy prosty endpoint w Express + TypeScript, ale bez walidacji:

type LoginDto = {
  email: string;
  password: string;
};

app.post("/login", async (req, res) => {
  const body: LoginDto = req.body; // rzutowanie „na wiarę”

  const user = await authService.login(body.email, body.password);
  res.json(user);
});

Jeśli ktoś wyśle:

{
  "email": 123,
  "password": null
}

TypeScript nie ma nic do powiedzenia. Rzutowanie as LoginDto tylko uspokoi kompilator. Na produkcji dostaniesz błędy typu:

  • body.email.toLowerCase is not a function,
  • Cannot read properties of null (reading 'length'),
  • lub co gorsza – logowanie przepuści zły stan dalej.

Czyli komfort typów w kodzie, a jednocześnie pełna losowość na wejściu.

Konsekwencje braku walidacji runtime

Brak walidacji danych wejściowych w TypeScript to nie tylko brzydkie błędy w logach. To także:

  • luki bezpieczeństwa – np. SQL Injection, XSS, wstrzyknięcie nieoczekiwanych struktur JSON,
  • niewidoczne błędy domenowe – np. ujemne ceny, daty w przeszłości, dziwne statusy enumów,
  • kruchy kod – każdy nowy konsument API może nieświadomie wysłać coś niewłaściwego,
  • trudne do odtworzenia bugi – bo błąd zależy od specyficznych danych wysłanych w konkretnym momencie.

To wszystko dzieje się mimo „idealnych” typów w projekcie. Typy nie walidują danych użytkownika – walidują Twoje użycie tych danych w kodzie.

Diagnoza: gdzie dziś przyjmujesz dane „na wiarę”?

Zatrzymaj się na chwilę i przeleć w myślach swój projekt. Odpowiedz sobie:

  • czy gdzieś rzutujesz req.body as SomeType bez faktycznej walidacji?
  • czy parsujesz JSON z localStorage i od razu go używasz?
  • czy dane z process.env są sprawdzane, czy tylko „zakładasz”, że są poprawne?
  • czy formularze na froncie mają taką samą logikę walidacji jak backend (czy może „jakoś podobną”)?

Im więcej „jakoś”, „powinno być dobrze” i „przecież mamy TypeScript”, tym bardziej opłaca się spojrzeć na dedykowaną walidację danych: Zod, Joi lub inne narzędzie.

Programista piszący kod TypeScript na laptopie podczas pracy nad projektem
Źródło: Pexels | Autor: Lukas Blazek

Kim są gracze? Krótka charakterystyka Zod i Joi

Joi – klasyk ze świata Node i Hapi

Joi to jedna z najstarszych i najbardziej rozpoznawalnych bibliotek do walidacji schema-based w ekosystemie Node.js. Powstała w środowisku frameworka Hapi, ale szybko rozlała się na Expressa, Koa i wszelkiego rodzaju serwisy backendowe w czystym JavaScript.

Joi jest schema-first dla świata JS. Najpierw definiujesz schemat danych (łańcuchowo, za pomocą fluent API), a potem używasz go do walidowania obiektów. Długo przed popularyzacją TypeScript Joi rozwiązywało realny problem: jak w jednym miejscu opisać strukturę danych, ich wymagania i reguły biznesowe.

Typowy przykład walidacji e‑maila i hasła w Joi:

import Joi from "joi";

const loginSchema = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().min(8).max(128).required()
});

W świecie typowego backendu JS to jest naturalny, płynny styl. W TS dochodzi jednak problem: schemat zdefiniowany w Joi nie generuje typów. Trzeba utrzymywać typy osobno, albo używać dodatkowych narzędzi.

Zod – „type-first schema validation” dla TypeScript

Zod powstał znacznie później, już w czasach dominacji TypeScriptu w frontendzie i coraz częściej na backendzie (NestJS, tRPC, Next.js API routes). Główna obietnica: schemat jako jedno źródło prawdy, z którego TypeScript automatycznie wyprowadza typy.

Przykładowy schemat logowania w Zod:

import { z } from "zod";

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8).max(128)
});

type LoginInput = z.infer<typeof loginSchema>;

Schemat i typ opisują dokładnie to samo, ale definicja jest tylko w jednym miejscu. Nie tworzysz osobnego type LoginInput ręcznie, nie pilnujesz synchronizacji. Zmiana schematu automatycznie aktualizuje typ.

To podejście jest naturalne dla TypeScript: programujesz type-first. Tworzysz schemat, a kompilator i edytor resztę „domyślają się” za Ciebie.

Jakie problemy każdy z nich próbuje rozwiązać?

Joi został stworzony z myślą o:

  • dużych bazach kodu w JavaScript (bez TS),
  • walidacji requestów HTTP, payloadów i konfiguracji,
  • bogatych, deklaratywnych regułach walidacji (np. wzajemne zależności pól),
  • integracji z backendowymi frameworkami (Hapi, Express, itp.).

Zod z kolei wyrósł na gruncie:

  • projektów pisanych od zera w TypeScript,
  • współdzielenia schematów między frontendem i backendem (monorepo, tRPC, Next.js),
  • silnego type inference i ścisłej współpracy z IDE,
  • walidacji + transformacji w jednym kroku, bez utraty typów.

Czyli: Joi rozwiązuje problem walidacji w runtime dla JS, Zod – walidacji i typów jednocześnie dla TS.

Kiedy w ogóle rozważać Zod, Joi albo coś jeszcze innego?

Zanim zaczniesz porównywać szczegóły, odpowiedz sobie szczerze: jaki masz cel? Chcesz:

  • tylko zweryfikować requesty na backendzie JS, bez TS? – Joi będzie zupełnie wystarczający,
  • mieć jedno źródło prawdy dla typów i schematów w TypeScript? – Zod ma przewagę,
  • współdzielić schematy między frontem a backendem w monorepo? – Zod jest naturalnym wyborem,
  • walidować wyłącznie na poziomie formularzy HTML/React, bez wielkich schematów? – czasem wystarczy lekki zestaw reguł lub biblioteka formularzy z walidacją.

Jeśli projekt to stara baza JS z rozbudowanym kodem opartym na Joi, migracja na Zod może być kosztowna i niepotrzebna. Jeśli jednak startujesz lub przepinasz się na pełny TypeScript, warto bardzo poważnie rozważyć Zod jako centralne narzędzie do walidacji i typów.

Type inference i współpraca z TypeScript – miejsce, gdzie Zod błyszczy

Schemat jako źródło prawdy: z.infer w praktyce

Największa przewaga Zod nad Joi w kontekście TypeScriptu to type inference. Każdy schemat Zod ma odpowiadający mu typ, który można automatycznie wyprowadzić przez z.infer. Kod:

const userSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string().min(1),
  createdAt: z.date()
});

type User = z.infer<typeof userSchema>;

daje typ:

type User = {
  id: string;
  email: string;
  name: string;
  createdAt: Date;
};

Bez żadnego dodatkowego pisania. Dodajesz nowe pole w schemacie – typ aktualizuje się od razu. Zmieniasz name na opcjonalne (z.string().optional()) – typ User["name"] automatycznie staje się string | undefined.

Brak duplikacji definicji w porównaniu z Joi

W Joi naturalny schemat wygląda np. tak:

const userSchema = Joi.object({
  id: Joi.string().guid({ version: "uuidv4" }).required(),
  email: Joi.string().email().required(),
  name: Joi.string().min(1).required(),
  createdAt: Joi.date().required()
});

A typ w TypeScript trzeba zdefiniować ręcznie, np.:

type User = {
  id: string;
  email: string;
  name: string;
  createdAt: Date;
};

Co się dzieje przy refaktoryzacji? Jeśli zmienisz pole w userSchema, ale zapomnisz poprawić typ User, kompilator nie zawsze wykryje rozjazd. Efekt: dane przechodzą walidację, ale typy w kodzie opisują inny kształt obiektu niż w rzeczywistości.

Przykładowy błąd z życia: backendowiec zmienia name na opcjonalne w Joi, ale typ w TS nadal ma name: string. Frontend spodziewa się zawsze stringa, zaczyna używać user.name.toUpperCase() bez sprawdzania, a na produkcji trafia się user bez name i mamy klasyczne: Cannot read property 'toUpperCase' of undefined.

Refaktoryzacja a wsparcie edytora w Zod

Zastanów się: ile razy zmieniałeś model domenowy (np. User, Order, Product) i musiałeś:

  • aktualizować typy w kilku plikach,
  • szukać wszystkich odwołań do pól ręcznie,
  • łatać błędy dopiero po uruchomieniu testów albo aplikacji.

W Zod możesz oprzeć się o centralne schematy i mechanizmy typu z.infer, z.input, z.output. Refaktoryzacja sprowadza się do:

  • zmiany w jednym pliku z definicją schematu,
  • przejechania błędów kompilacji, które dokładnie pokażą, gdzie coś się nie zgadza,
  • upewnienia się, że transformacje i walidacje nadal są zgodne z wymaganiami.

IDE ma dostęp do pełnego kształtu danych wynikających z walidacji. Może więc podpowiadać właściwe pola, ostrzegać, jeśli próbujesz użyć nieistniejącego klucza, i pomagać w refaktoryzacji typu „rename symbol” bez strachu.

Ręczna synchronizacja typów przy Joi – jak bardzo to boli w dużych projektach?

Przy małych DTO (np. 3–4 pola) jeszcze da się to znieść. Przy kilkudziesięciu modelach i zagnieżdżonych strukturach utrzymywanie spójności:

  • schematów Joi,
  • typów TS,
  • interfejsów na froncie,
  • definicji API (np. OpenAPI/Swagger)

Jak uniknąć „dryfu schematów” przy Joi?

Na pewnym etapie projekt rośnie, zespół się zmienia, dochodzą nowe funkcje. Schematy i typy zaczynają żyć własnym życiem. Pytanie: jak bardzo chcesz polegać na dyscyplinie programistów, a jak bardzo na narzędziach?

W przypadku Joi masz kilka strategii, żeby ograniczyć ręczną synchronizację:

  • budowanie typów TS na bazie schematów poprzez zewnętrzne generatory (np. narzędzia czytające definicje Joi i produkujące .d.ts),
  • trzymanie modeli domenowych jako źródła prawdy (np. klasy/typy TS) i generowanie z nich schematów walidacji (czasem półautomatycznie, czasem makrami),
  • silne testy kontraktów między warstwami (np. backend ↔ frontend ↔ BFF) – testy end‑to‑end i kontraktowe wyłapujące rozjazdy.

Każde z tych rozwiązań wymaga jednak dodatkowego kleju. Przy Zod taki „klej” jest wbudowany w samą bibliotekę: typ i schemat są jednym artefaktem.

Zastanów się: chcesz inwestować czas w pisanie i utrzymanie generatorów, czy w samo rozwiązywanie problemów biznesowych? Jeśli wybierasz pierwsze – Joi w TS da się ogarnąć. Jeśli drugie – Zod zazwyczaj przyspiesza pracę.

Zod poza „prostymi obiektami” – unie, przecięcia, mapy

Type inference Zoda nie zatrzymuje się na prostych DTO. Gdy wchodzisz w bardziej złożone typy – unie i przecięcia – różnica w wygodzie względem Joi rośnie.

Przykład unii typów („discriminated union”) w Zod:

const emailNotification = z.object({
  type: z.literal("email"),
  email: z.string().email(),
  subject: z.string(),
});

const smsNotification = z.object({
  type: z.literal("sms"),
  phone: z.string().regex(/^+?[0-9]{9,15}$/),
});

const notificationSchema = z.discriminatedUnion("type", [
  emailNotification,
  smsNotification,
]);

type Notification = z.infer<typeof notificationSchema>;

Dzięki discriminatedUnion TypeScript w funkcjach operujących na Notification potrafi automatycznie zawężać typ po sprawdzeniu pola type:

function sendNotification(n: Notification) {
  if (n.type === "email") {
    // TS wie, że n.email i n.subject istnieją
  } else {
    // TS wie, że to SMS, więc mamy n.phone
  }
}

Przy Joi typy takich unii trzeba dopisywać ręcznie, a zawężanie opiera się głównie na Twojej uważności i testach.

Programista piszący kod TypeScript na laptopie z naciskiem na bezpieczeństwo
Źródło: Pexels | Autor: cottonbro studio

Składnia i ergonomia: definicja schematów w Zod i Joi krok po kroku

Proste typy i łańcuchowe reguły

Zacznijmy od najprostszych schematów. Jak opisałbyś zwykły string z ograniczeniem długości? W Joi:

const schema = Joi.string().min(3).max(50).required();

W Zod:

const schema = z.string().min(3).max(50);

Różnice są kosmetyczne, ale od razu widać jedną rzecz: w Joi .required() jest bardzo częste, bo domyślnie pola są opcjonalne w obiekcie. W Zod pole jest traktowane jako wymagane, dopóki nie dodasz .optional() lub nie użyjesz .partial() na obiekcie.

Zadaj sobie krótkie pytanie: w Twoim modelu domenowym dane są z zasady opcjonalne czy raczej większość pól jest wymagana? Od tego zależy, która semantyka będzie mniej zaskakiwać.

Obiekty i zagnieżdżone struktury

Typowy obiekt adresu w Joi:

const addressSchema = Joi.object({
  street: Joi.string().required(),
  city: Joi.string().required(),
  zip: Joi.string().required(),
  country: Joi.string().default("PL")
});

Ten sam obiekt w Zod:

const addressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zip: z.string(),
  country: z.string().default("PL"),
});

W obu przypadkach da się modelować zagnieżdżenia:

const userSchemaJoi = Joi.object({
  id: Joi.string().required(),
  address: addressSchema.required()
});

const userSchemaZod = z.object({
  id: z.string(),
  address: addressSchema,
});

Dla samej walidacji runtime obie wersje spełniają tę samą rolę. Różnica pojawia się, gdy chcesz użyć typu User w kodzie TS – Zod przychodzi z gotowym z.infer, natomiast przy Joi musisz sam zdefiniować interfejs.

Opcjonalność, wartości domyślne i częściowe obiekty

Częściej niż całe modele zmieniają się fragmenty: DTO do „create” ma inne wymagania niż DTO do „update”. Jak to zapisać ergonomicznie?

Zod ma wbudowane metody modyfikujące istniejące schematy:

const baseUser = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string().min(1),
});

const createUserInput = baseUser.omit({ id: true });
const updateUserInput = baseUser.partial();

Typy wyprowadzone z tych schematów są automatycznie dopasowane: przy partial() każde pole staje się opcjonalne, przy omit i pick typy są odpowiednio cięte.

W Joi coś podobnego da się osiągnąć, ale głównie na poziomie runtime:

const baseUser = Joi.object({
  id: Joi.string().guid({ version: "uuidv4" }).required(),
  email: Joi.string().email().required(),
  name: Joi.string().min(1).required(),
});

const createUserSchema = baseUser.fork(["id"], (schema) => schema.forbidden());
const updateUserSchema = baseUser.fork(["email", "name"], (schema) => schema.optional());

Modele TS trzeba jednak zaktualizować ręcznie. Pytanie brzmi: jak często zmieniasz DTO w projekcie? Jeśli co chwilę – przewaga Zoda w ergonomii i bezpieczeństwie typów szybko się kumuluje.

Tablice, rekordy, mapy

Przykład listy tagów w Joi:

const tagsSchema = Joi.array().items(Joi.string().max(20)).max(10);

I w Zod:

const tagsSchema = z.array(z.string().max(20)).max(10);

Dalej – obiekt dynamicznych kluczy (np. mapa ustawień feature flag):

// Joi
const featureFlagsJoi = Joi.object().pattern(
  Joi.string(),
  Joi.boolean()
);

// Zod
const featureFlagsZod = z.record(z.boolean());

Na tym poziomie składni oba narzędzia są intuicyjne. Różnica: z featureFlagsZod od razu dostajesz typ Record<string, boolean> bez dodatkowej pracy.

Walidacja, parsowanie, transformacja – co dokładnie robi Zod, a co Joi

Czysta walidacja (Joi) kontra walidacja z parsowaniem (Zod)

Kluczowe pytanie: czy chcesz tylko sprawdzić dane, czy przy okazji je przekształcić? Joi tradycyjnie skupia się na walidacji – weryfikuje kształt, typy i reguły. Zod z kolei oferuje parsowanie: z wejścia (często typu unknown) zwraca już przetworzony, typowany obiekt.

Przykład z datą w Joi:

const schema = Joi.object({
  from: Joi.date().iso().required(),
  to: Joi.date().iso().required()
});

const { error, value } = schema.validate(input);

if (error) {
  // obsługa błędu
} else {
  // value.from i value.to są Date (jeśli nie wyłączysz konwersji)
}

Przykład w Zod:

const schema = z.object({
  from: z.coerce.date(),  // próba sparsowania z stringa/liczby
  to: z.coerce.date(),
});

const result = schema.safeParse(input);

if (!result.success) {
  // obsługa błędu
} else {
  const data = result.data; // typowany obiekt z Date
}

Zauważ różnicę w semantyce: Zod parsuje do znanego typu – obiekt wyjściowy ma jasno określony kształt wynikający ze schematu. Joi też umie konwertować (np. string → Date), ale z perspektywy TypeScriptu ta informacja nie jest automatycznie przenoszona na typy.

Transformacje danych w Zod: pipe, transform i refinements

Masz sytuację, w której przychodzi string, ale w kodzie chcesz pracować na liczbie? Zod pozwala skleić walidację z transformacją:

const ageSchema = z
  .string()
  .regex(/^d+$/)
  .transform((val) => Number(val))
  .refine((age) => age >= 18 && age <= 120, {
    message: "Age must be between 18 and 120",
  });

type Age = z.infer<typeof ageSchema>; // number

Wejściem jest string, wyjściem – liczba. Typ Age opisuje już przetworzoną wartość, a nie surowy payload. To szczególnie przydaje się przy formularzach, gdzie wszystko wpada jako string, ale logika aplikacji oczekuje liczb, dat czy enumów.

Joi ma .custom i .alter, które pozwalają implementować niestandardowe walidacje i modyfikacje wartości:

const ageSchemaJoi = Joi.string()
  .regex(/^d+$/)
  .custom((value, helpers) => {
    const age = Number(value);
    if (age < 18 || age > 120) {
      return helpers.error("any.invalid");
    }
    return age;
  });

Tyle że TypeScript nie wie, że z string zrobiła się number. To znowu prowadzi do ręcznego dopisywania typów albo rzutowań.

Walidacja asynchroniczna

Coraz częściej walidacja nie kończy się na „czy string wygląda jak email”, ale wymaga zewnętrznych zasobów: zapytania do bazy, do zewnętrznego API, sprawdzenia unikalności. Jak wygodnie łączysz walidację z IO?

Joi obsługuje asynchroniczne walidacje przez funkcję validateAsync oraz .external():

const schema = Joi.object({
  email: Joi.string().email().required(),
});

const schemaWithCheck = schema.external(async (value) => {
  const exists = await checkEmailInDb(value.email);
  if (exists) {
    throw new Error("Email already taken");
  }
});

try {
  const value = await schemaWithCheck.validateAsync(input);
} catch (err) {
  // err zawiera informacje o błędach, w tym z asynchronous external
}

Zod również ma asynchroniczne API:

const schema = z.object({
  email: z.string().email(),
}).superRefine(async (value, ctx) => {
  const exists = await checkEmailInDb(value.email);
  if (exists) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Email already taken",
      path: ["email"],
    });
  }
});

const result = await schema.safeParseAsync(input);

W obu bibliotekach da się wygodnie łączyć walidację z zapytaniami IO. Różnica pojawia się głównie na poziomie typów: po safeParseAsync nadal masz pewność, że result.data jest zgodne z wyprowadzonym typem TS.

Casting i „magiczne” konwersje – gdzie się potknięto w praktyce?

Domyślne konwersje potrafią być pułapką. Joi historycznie bywał skonfigurowany tak, że liczby w stringach były automatycznie rzutowane na number, puste stringi na null itd. Czy w Twoim zespole każdy wie dokładnie, jakie konwersje są włączone?

Zod podchodzi do tego ostrożniej – dopóki nie użyjesz z.coerce lub transform, raczej nic się nie stanie „za Twoimi plecami”. To zmusza do bardziej świadomych decyzji, ale też ogranicza niespodzianki.

Jeśli zaczynasz projekt, zadaj sobie pytanie: chcesz, aby biblioteka „pomagała” domyślnym castingiem, czy wolisz całkowitą jawność transformacji? W backendach integrujących się z wieloma źródłami danych jawność zwykle wygrywa. W prostych API formularzowych – automatyczny casting potrafi skrócić kod.

Zbliżenie ekranu komputera z kolorowym kodem w edytorze programu
Źródło: Pexels | Autor: Mathews Jumba

Obsługa błędów, komunikaty i UX dla programisty oraz użytkownika

Struktura błędów w Joi

Joi zwraca obiekt błędu zawierający listę szczegółów (details). Każdy element opisuje pojedyncze naruszenie reguły:

const schema = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().min(8).required(),
});

const { error } = schema.validate(input, { abortEarly: false });

if (error) {
  console.log(error.details);
}

Przykładowa struktura error.details:

[
  {
    message: '"email" must be a valid email',
    path: ['email'],
    type: 'string.email',
    context: { ... }
  },
  {
    message: '"password" length must be at least 8 characters long',
    path: ['password'],
    type: 'string.min',
    context: { ... }
  }
]

To wygodne do budowania komunikatów dla użytkownika (frontend) lub logów. Z punktu widzenia TypeScriptu error ma znaną strukturę, ale nie jest powiązany z konkretnym typem modelu – nie ma tu generics.

Struktura błędów w Zod

Jak Zod opisuje błędy i co z tego masz w TypeScript

Zod w razie niepowodzenia zwraca instancję ZodError. Najczęściej spotykana forma przy pracy z safeParse wygląda tak:

const result = schema.safeParse(input);

if (!result.success) {
  const zodError = result.error;
  console.log(zodError.issues);
}

Kluczowe jest pole issues – tablica drobnych, szczegółowych błędów:

[
  {
    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)'
  }
]

Struktura jest bardziej „kodocentryczna”: code, expected, received, dodatkowe pola zależne od rodzaju błędu. To ułatwia automatyczne mapowanie błędów na komunikaty UI lub kody HTTP. Łatwiej też pisać funkcje pomocnicze, które np. wyciągają błąd konkretnego pola.

Przykład prostej funkcji do frontendu formularza:

function getFieldError(
  error: z.ZodError | undefined,
  path: (string | number)[]
): string | null {
  if (!error) return null;

  const issue = error.issues.find((issue) =>
    issue.path.join(".") === path.join(".")
  );

  return issue?.message ?? null;
}
// getFieldError(error, ["email"]) -> "Expected string, received number"

Pytanie do Ciebie: chcesz budować własne struktury błędów pod UI, czy raczej dopasować się do tego, co zwraca biblioteka? Od odpowiedzi zależy, czy wygodniej będzie Ci pracować z kodami Zoda, czy z bardziej „ludzkimi” komunikatami Joi.

Customowe komunikaty i internacjonalizacja

Zarówno w Joi, jak i w Zodzie da się nadpisać domyślne komunikaty. Często kończy się to plikiem z mapą błędów i prostym systemem i18n.

W Zodzie wiele rzeczy da się ustawić lokalnie:

const loginSchema = z.object({
  email: z.string().email({ message: "Nieprawidłowy adres email" }),
  password: z.string().min(8, { message: "Hasło musi mieć min. 8 znaków" }),
});

Można też globalnie ustawić funkcję errorMap:

import { z, ZodErrorMap } from "zod";

const errorMap: ZodErrorMap = (issue, ctx) => {
  if (issue.code === z.ZodIssueCode.invalid_type) {
    return { message: "Nieprawidłowy typ danych" };
  }
  return { message: ctx.defaultError };
};

z.setErrorMap(errorMap);

W praktyce często łączysz to z własnym systemem tłumaczeń. Zwracasz nie gotowy tekst, tylko np. klucz:

const errorMap: ZodErrorMap = (issue, ctx) => {
  if (issue.code === z.ZodIssueCode.too_small && issue.path[0] === "password") {
    return { message: "error.password.too_short" };
  }
  return { message: ctx.defaultError };
};

W Joi podobny efekt osiągasz przez metodę messages na schemacie lub globalną konfigurację. Jeśli masz już rozbudowaną bazę tłumaczeń pod Joi, migracja na Zoda będzie wymagała przemyślenia mapowania błędów – nie licz na bezbolesne kopiuj-wklej.

Mapowanie błędów na odpowiedzi API

Jak chcesz, żeby wyglądał payload błędu HTTP 400? Czy klienci API mają dostać mapę field -> message, czy listę błędów? W obu bibliotekach da się to zbudować, ale w Zodzie jest to zwykle prostsze.

Przykładowa funkcja dla Zoda na backendzie Express:

import { z, ZodError } from "zod";
import { Request, Response, NextFunction } from "express";

function handleZodError(err: ZodError, res: Response) {
  const errors: Record<string, string> = {};

  for (const issue of err.issues) {
    const path = issue.path.join(".");
    if (!errors[path]) {
      errors[path] = issue.message;
    }
  }

  res.status(400).json({ errors });
}

function validateBody<T extends z.ZodTypeAny>(schema: T) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);

    if (!result.success) {
      return handleZodError(result.error, res);
    }

    // podmieniamy body na sparsowane i typowane dane
    req.body = result.data;
    next();
  };
}

Joi wymaga podobnej obudowy, ale nie masz „za darmo” nadpisanego typu req.body. Jeśli chcesz mieć to w TS, kończy się na genericsach / deklaracjach modułowych:

declare module "express-serve-static-core" {
  interface Request {
    validatedBody?: MyDto; // manualnie
  }
}

Jakie masz założenia dla warstwy transportu? Jeśli chcesz unikać rzutowań i „opcjonalnych” pól na requestach w całym kodzie, to powiązanie walidacji z typami Zoda daje sporą przewagę UX-ową dla zespołu.

Obsługa błędów a DX: try/catch vs. safeParse

W Joi typowo łapiesz błędy przez try/catch lub analizę error zwracanego z validate. Zod preferuje rozdział ścieżek: sukces lub porażka.

const parsed = schema.safeParse(input);

if (!parsed.success) {
  // parsed.error: ZodError
} else {
  // parsed.data: typowany obiekt
}

Jeśli masz zespół, w którym część osób ma nawyk „łapię wszystkie błędy i loguję”, to safeParse jest bezpieczniejsze – nie mieszają się wyjątki programistyczne z błędami walidacji. W dużych aplikacjach rozdział tych dwóch ścieżek oszczędza sporo czasu przy debugowaniu.

Integracja z popularnymi narzędziami: React, Next.js, Express, NestJS

React i formularze: React Hook Form, Formik i spółka

Przy formularzach najważniejsze pytanie brzmi: kto ma być „źródłem prawdy” dla kształtu danych – schema, czy model komponentu? Jeśli stawiasz na schemę, Zod wygrywa typami.

Najpopularniejsza para to React Hook Form + Zod. Przykładowa integracja:

import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

const schema = z.object({
  email: z.string().email(),
  age: z.coerce.number().min(18),
});

type FormValues = z.infer<typeof schema>;

function ProfileForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
  });

  const onSubmit = (data: FormValues) => {
    // data.email: string, data.age: number
  };

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

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

Cały przepływ danych – od inputa, przez walidację, po handler – oparty jest na jednym typie wyprowadzonym z Zoda. Nie synchronizujesz ręcznie FormValues z definicją walidacji.

Z Joi też da się to ograć (np. własny resolver pod React Hook Form), ale tracisz z.infer. Kończy się zwykle na ręcznym interfejsie FormValues albo generowaniu typów innym narzędziem.

Zadaj sobie pytanie: jak często zmieniają się formularze? W aplikacji SaaS, gdzie formularzy jest kilkadziesiąt i ciągle się zmieniają, dublowanie definicji (schema + interfejs) szybko staje się utrapieniem.

Next.js: API Routes i Server Actions

W Next.js Zod pojawia się naturalnie w kilku miejscach:

  • walidacja ciała requestu w /app/api/... lub /pages/api/...,
  • weryfikacja parametrów URL / search params,
  • walidacja argumentów Server Actions.

Przykład prostego endpointu w katalogu app:

// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";

const createUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

export async function POST(req: NextRequest) {
  const json = await req.json();
  const parsed = createUserSchema.safeParse(json);

  if (!parsed.success) {
    return NextResponse.json(
      { errors: parsed.error.flatten() },
      { status: 400 }
    );
  }

  const user = await createUser(parsed.data);
  return NextResponse.json(user, { status: 201 });
}

Metoda error.flatten() zwraca strukturę przyjazną formularzom: fieldErrors i formErrors.

const { fieldErrors, formErrors } = parsed.error.flatten();

// fieldErrors.email -> string[]
// fieldErrors.password -> string[]

Przy Server Actions schematy Zoda często lądują tuż obok handlerów:

"use server";

const formSchema = z.object({
  email: z.string().email(),
  age: z.coerce.number().min(18),
});

export async function submit(formData: FormData) {
  const raw = {
    email: formData.get("email"),
    age: formData.get("age"),
  };

  const parsed = formSchema.safeParse(raw);

  if (!parsed.success) {
    return { success: false, errors: parsed.error.flatten() };
  }

  await saveUser(parsed.data);
  return { success: true };
}

Joi też można wpiąć w te miejsca, ale brak safeParse i inferencji sprawia, że dodatkowy kod pomocniczy rośnie szybciej. Jeśli Twoim celem jest „jedno źródło prawdy” dla payloadów API, Zod pasuje do filozofii Next.js dużo naturalniej.

Express: middleware walidujące body, query i params

W klasycznym Expressie walidacja jest zwykle robiona w middleware. Tu wybór między Zod a Joi sprowadza się do pytania: czy dana aplikacja będzie w większości korzystać z TypeScriptu po stronie serwera?

Przykładowy zestaw middleware z Zodem:

const bodySchema = z.object({
  name: z.string().min(1),
  age: z.coerce.number().int().min(0),
});

type Body = z.infer<typeof bodySchema>;

function validateBody<S extends z.ZodTypeAny>(schema: S) {
  return (req: Request, res: Response, next: NextFunction) => {
    const parsed = schema.safeParse(req.body);

    if (!parsed.success) {
      return res.status(400).json({
        errors: parsed.error.flatten(),
      });
    }

    (req as any).validatedBody = parsed.data;
    next();
  };
}

// użycie
app.post("/users", validateBody(bodySchema), (req, res) => {
  const body: Body = (req as any).validatedBody;
  // typowany body
});

Można pójść krok dalej i rozszerzyć typy Expressa tak, aby req.validatedBody był generowany per route. To typowe w projektach, które traktują schemy Zoda jako źródło prawdy dla kontraktów API.

Z Joi pattern jest podobny, ale pewna liczba rzutowań as staje się niemal nieunikniona, bo TS nie wie, co Joi zwróci po walidacji i ewentualnych konwersjach.

NestJS: pipes, DTO i konflikt paradygmatów

NestJS ma swój domyślny sposób na walidację: klasy DTO + class-validator + class-transformer. Zod i Joi da się podłączyć, ale trzeba zdecydować, z którego świata korzystasz bardziej – klas czy schem.

W integracji z Joi NestJS ma wsparcie „z pudełka” (np. walidacja konfiguracji). Zod natomiast coraz częściej pojawia się w roli alternatywy dla class-validator, poprzez niestandardowe pipes.

Przykładowy ZodValidationPipe:

import { PipeTransform, BadRequestException } from "@nestjs/common";
import { z, ZodTypeAny } from "zod";

export class ZodValidationPipe implements PipeTransform {
  constructor(private schema: ZodTypeAny) {}

  transform(value: unknown) {
    const parsed = this.schema.safeParse(value);

    if (!parsed.success) {
      throw new BadRequestException(parsed.error.flatten());
    }

    return parsed.data;
  }
}

Użycie w kontrolerze:

const createUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

type CreateUserDto = z.infer<typeof createUserSchema>;

@Controller("users")
export class UsersController {
  @Post()
  create(
    @Body(new ZodValidationPipe(createUserSchema))
    body: CreateUserDto,
  ) {
    // body jest typowane przez Zoda
  }
}

Joi można wpiąć podobnie, ale nadal manualnie ustalasz typ CreateUserDto. Jeśli Twój zespół jest już przyzwyczajony do klas DTO i adnotacji, integracja z Zodem będzie wymagała zmiany nawyków. W zamian dostajesz pełne typowanie bez magii refleksji.

Walidacja konfiguracji: proces.env, pliki .env, config module

Konfiguracja aplikacji (env, config files) to miejsce, gdzie Zod w ekosystemie TypeScript ma sporą przewagę nad Joi. Schematy opisują kształt konfiguracji, walidują przy starcie i od razu zapewniają typy w całym projekcie.

Prosty przykład z konfiguracją bazy danych:

Najczęściej zadawane pytania (FAQ)

Po co używać Zod lub Joi w TypeScript, skoro i tak mam typy?

Typy w TypeScript działają tylko na etapie kompilacji i w edytorze. W wygenerowanym JavaScripcie nie ma już informacji o typach, więc w runtime funkcja może dostać zupełnie inne dane, niż obiecał typ – choćby null zamiast stringa.

Zod i Joi działają w runtime: biorą „surowe” dane (np. req.body, JSON z API, zmienne środowiskowe), sprawdzają ich strukturę i wartości, a w razie problemu zwracają czytelny błąd. Zastanów się: w ilu miejscach w kodzie zakładasz, że dane „na pewno są poprawne”, bo kompilator się nie skarży?

Jaka jest główna różnica między Zod a Joi w projektach TypeScript?

Joi historycznie powstał dla czystego JavaScriptu – definiujesz schemat i walidujesz dane, ale typy musisz utrzymywać osobno albo generować dodatkowymi narzędziami. Zod został zaprojektowany pod TypeScript: z jednego schematu generuje zarówno walidację runtime, jak i typy (przez z.infer).

Jeśli pracujesz głównie w TS i chcesz, by schemat był jednym źródłem prawdy, Zod zwykle wygrywa. Jeśli masz stary kod JS mocno oparty na Joi i TypeScript jest „doklejony z boku”, prostsze będzie pozostanie przy Joi. Jak wygląda Twój projekt: nowy TS-first czy rozbudowany, legacy JS?

Kiedy lepiej wybrać Zod, a kiedy Joi do walidacji danych?

Zod sprawdza się, gdy:

  • startujesz nowy projekt w TypeScript lub właśnie na TS migrujesz,
  • chcesz współdzielić schematy między frontendem i backendem (np. monorepo, tRPC, Next.js),
  • szukasz mocnego wsparcia IDE i inference typów bez ręcznego dopisywania typeów.

Joi bywa lepszym wyborem, gdy:

  • masz duży, istniejący backend w JavaScript (Hapi, Express) z już zdefiniowanymi schematami Joi,
  • TypeScript jest używany tylko częściowo albo w ogóle go nie ma,
  • priorytetem jest sama walidacja runtime, a nie integracja z systemem typów.

Pomyśl: co jest Twoim celem – wygoda w TS i jedno źródło prawdy, czy raczej szybka walidacja w istniejącym JS?

Czy TypeScript nie wystarczy do zabezpieczenia API przed złymi danymi?

TypeScript zabezpiecza Twój kod przed błędnym użyciem typów przez programistów, ale nie chroni przed tym, co przyjdzie „z drutu” – z requestu HTTP, formularza, bazy czy zewnętrznego API. Kompilator widzi CreateUserInput, ale nie widzi realnego JSON-a wysłanego przez klienta.

Bez walidacji runtime kończysz z rzutowaniem „na wiarę” (req.body as LoginDto) i błędami typu Cannot read properties of null w produkcji. Zastanów się: gdzie w Twoim kodzie używasz as SomeType na danych z zewnątrz bez żadnego sprawdzenia?

Jakie typowe błędy eliminuje walidacja runtime przy użyciu Zod lub Joi?

Walidacja runtime usuwa przede wszystkim błędy związane ze strukturą i typami danych na wejściu. Przykłady z codziennej pracy:

  • pola o złym typie (np. email: 123 zamiast string),
  • brak wymaganych pól (password: null zamiast hasła),
  • wartości spoza domeny biznesowej (ujemne kwoty, daty w przeszłości, nieistniejące statusy),
  • „dodatkowe” pola, których nikt się nie spodziewa (np. podnoszące ryzyko wstrzyknięć).

Zadaj sobie pytanie: które błędy w logach pojawiają się tylko przy specyficznych payloadach od klienta i trudno je odtworzyć lokalnie? Właśnie te miejsca proszą się o porządną walidację.

Czy da się używać Zod lub Joi jednocześnie na frontendzie i backendzie?

Tak, zwłaszcza Zod jest do tego często wykorzystywany. Ten sam schemat może walidować dane formularza w React na froncie i requesty HTTP lub wejścia do serwisów domenowych na backendzie. To zmniejsza ryzyko sytuacji „na froncie przechodzi, na backendzie się wysypuje”, bo walidacja jest wspólna.

Joi da się używać w przeglądarce, ale w praktyce częściej zostaje na backendzie. Jeśli planujesz monorepo i współdzielone typy, zadaj sobie pytanie: chcesz jeden schemat dla całego flow danych, czy osobne reguły po obu stronach?

Czy warto migrować istniejący projekt z Joi na Zod?

To zależy od tego, w jakim miejscu jest Twój projekt. Jeśli masz dużą bazę kodu JS z dziesiątkami/ setkami schematów Joi i dopiero dokładasz TS, migracja może być kosztowna, a zysk niewspółmierny. Wtedy sensowniejsze jest stopniowe otoczenie krytycznych miejsc typami, bez zmiany biblioteki walidacji.

Jeśli jednak i tak przepinasz aplikację na „prawdziwy” TypeScript, rozważ, czy nie lepiej od razu budować nowe moduły w podejściu type-first z Zod. Dobre pytanie pomocnicze: gdzie projekt będzie za rok – dalej JS z domieszką TS, czy raczej solidny TS-first?