Zod 4, walidacja schematami i migracja z wersji 3
Zod opisuje kształt danych i sprawdza, czy to, co przyszło z zewnątrz, ten kształt ma. Przy okazji wyprowadza z opisu typ, więc jeden schemat pełni dwie role naraz: chroni w czasie działania i typuje w czasie kompilacji.
To połączenie rozwiązuje problem, którego same typy nie rozwiązują. Interfejs opisujący odpowiedź z serwera jest deklaracją, w którą kompilator wierzy na słowo. Schemat jest sprawdzeniem, które albo przejdzie, albo nie, a typ wynika z niego automatycznie i nie potrafi się z nim rozjechać.
Podstawowy układ
import { z } from 'zod'
const Uzytkownik = z.object({
id: z.uuid(),
email: z.email(),
wiek: z.number().int().min(18),
rola: z.enum(['admin', 'redaktor', 'czytelnik']),
})
type Uzytkownik = z.infer<typeof Uzytkownik>
const wynik = Uzytkownik.safeParse(daneZSerwera)
if (!wynik.success) {
console.error(wynik.error.issues)
} else {
wynik.data.email
}Dwie rzeczy w tym kodzie są ważniejsze niż reszta.
Pierwsza to z.infer, czyli wyprowadzenie typu ze schematu. Dzięki temu istnieje jedno źródło prawdy zamiast dwóch, a zmiana schematu natychmiast zapala błędy tam, gdzie kod używał starego kształtu.
Druga to safeParse zamiast parse. Pierwszy zwraca wynik do sprawdzenia, drugi rzuca wyjątek. Przy danych od użytkownika prawie zawsze chcesz tego pierwszego, bo błędny formularz nie jest sytuacją wyjątkową, tylko normalnym przebiegiem.
Gdzie ta biblioteka naprawdę pracuje
Warto nazwać miejsca, w których się opłaca, bo dodawanie schematów wszędzie jest kosztem bez zwrotu.
Granica systemu jest właściwym miejscem: odpowiedź z zewnętrznego interfejsu, treść żądania przychodzącego, dane z formularza, zmienne środowiskowe przy starcie aplikacji, zawartość pliku konfiguracyjnego. Wszędzie tam dane pochodzą spoza TypeScriptu i typ napisany ręcznie jest wyłącznie życzeniem.
Zmienne środowiskowe zasługują na osobne zdanie, bo to najtańsza możliwa wygrana. Schemat sprawdzany przy starcie sprawia, że brakujący klucz zatrzymuje aplikację od razu, z czytelnym komunikatem, zamiast wywalać się o trzeciej nad ranem przy pierwszym żądaniu, które go potrzebowało.
import { z } from 'zod'
const Srodowisko = z.object({
DATABASE_URL: z.url(),
STRIPE_SECRET: z.string().startsWith('sk_'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
NODE_ENV: z.enum(['development', 'test', 'production'])
})
export const env = Srodowisko.parse(process.env)Przymus konwersji przy porcie jest tu istotny, bo zmienne środowiskowe zawsze przychodzą jako łańcuchy znaków. Bez niego schemat oczekujący liczby odrzuci poprawną wartość, a komunikat będzie mówił o typie zamiast o przyczynie.
Wnętrze aplikacji jest niewłaściwym miejscem. Dane, które przeszły walidację na wejściu, mają już typ i sprawdzanie ich ponownie między funkcjami dokłada kod i czas bez żadnej korzyści.
Wersja 4 i co się w niej zmieniło
Czwarta odsłona wyszła w 2025 roku i jest wydaniem, w którym najważniejsze są nie nowe możliwości, tylko trzy zmiany zachowania i jedna zmiana kształtu pakietu.
Metoda łącząca schematy została odradzona na rzecz rozszerzania albo rozwinięcia kształtu. Warto podkreślić, że nie zniknęła: .merge() nadal działa, dostał wyłącznie oznaczenie przestarzałego, więc kompilator niczego nie zatrzyma, a edytor co najwyżej przekreśli nazwę. Autorzy podają dwa powody zmiany: rozszerzanie nie gubi ustawień dotyczących nadmiarowych pól i szybciej sprawdza się w kompilatorze.
const Podstawa = z.object({ id: z.uuid() })
const Znacznik = z.object({ utworzono: z.iso.datetime() })
const Rekord = Podstawa.extend(Znacznik.shape)Rozwinięcie kształtu drugiego schematu działa też wtedy, gdy pola się pokrywają: wygrywa wersja z prawej strony. To zachowanie warto znać, bo cicha podmiana typu pola przy scalaniu dwóch schematów jest błędem, którego kompilator nie zgłosi.
Obsługa błędów została ujednolicona: zamiast kilku osobnych parametrów jest jeden, wspólny dla całego schematu. Liczba rodzajów zgłaszanych problemów spadła z piętnastu do jedenastu, co upraszcza kod przetwarzający błędy i zarazem wymaga jego przejrzenia.
Trzecia zmiana jest najgroźniejsza, bo kompiluje się bez słowa. Dotyczy wartości domyślnych i ma dwie osobne części, które łatwo pomylić ze sobą.
Pierwsza dotyczy pól opcjonalnych. Wartość domyślna wpisana w pole oznaczone jako opcjonalne jest teraz stosowana również wtedy, gdy klucza w danych wejściowych w ogóle nie było.
const Ustawienia = z.object({
motyw: z.string().default('jasny').optional()
})
Ustawienia.parse({})W poprzedniej wersji to wywołanie zwracało pusty obiekt, w czwartej zwraca obiekt z polem ustawionym na wartość domyślną. Kod sprawdzający obecność klucza zamiast jego wartości zacznie więc widzieć pole, którego wcześniej nie widział.
Druga część dotyczy przekształceń. Przy wejściu niezdefiniowanym walidacja zwraca dziś wartość domyślną od razu, bez przepuszczania jej przez schemat, więc ta wartość musi pasować do typu wyjściowego, a nie wejściowego. Schemat z.string().transform((v) => v.length).default(0) zwraca zero, podczas gdy jego odpowiednik z poprzedniej wersji z domyślną 'tuna' zwracał cztery. Stare zachowanie odtwarza nowa metoda .prefault(), czyli domyślna sprzed przetworzenia.
To są miejsca, które przy migracji trzeba przejrzeć ręcznie, bo wyszukiwarka nie znajdzie ich po żadnym słowie kluczowym.
Do tego dochodzi wymóg surowej konfiguracji kompilatora oraz nowsza wersja samego TypeScriptu, więc projekt na luźnych ustawieniach musi je najpierw zacieśnić.
Trzy warianty w jednym pakiecie
Kształt pakietu jest dziś nietypowy i to jest rzecz, która najbardziej ułatwia migrację.
Wariant podstawowy to znany interfejs z metodami łańcuchowymi. Wariant mini wystawia te same walidatory jako osobne funkcje, co pozwala narzędziu budującemu wyciąć wszystko, czego nie użyłeś. Trzeci wariant to poprzednia wersja, dostępna pod osobną ścieżką importu.
Ostatnia rzecz jest ważniejsza, niż wygląda. Dzięki niej obie wersje mogą działać w jednym projekcie równolegle, więc migrację da się przeprowadzić plik po pliku, zamiast jednym ruchem przez całe repozytorium.
import { z } from 'zod'
import { z as z3 } from 'zod/v3'
import * as zm from 'zod/mini'
const Nowy = z.object({ id: z.uuid() })
const Stary = z3.object({ id: z3.string().uuid() })
const Lekki = zm.object({ id: zm.string() })Przy dużym projekcie to różnica między tygodniem a popołudniem. Wariant lekki warto rozważyć w kodzie wysyłanym do przeglądarki, bo pozwala narzędziu budującemu wyciąć walidatory, których nie użyłeś.
Wariant mini warto rozważyć w kodzie wysyłanym do przeglądarki. Sam rdzeń biblioteki schudł w tej wersji ponad dwukrotnie, a wariant funkcyjny dokłada do tego możliwość usunięcia nieużywanych walidatorów. Po stronie serwera nie ma to znaczenia i tam wygodniejszy zapis łańcuchowy wygrywa.
Transformacje i schematy dwustronne
Schemat nie musi wyłącznie sprawdzać. Może też przekształcać, i to jest możliwość, która przy granicach systemu oszczędza sporo kodu.
const Zamowienie = z.object({
utworzono: z.string().transform((s) => new Date(s)),
kwotaGroszy: z.number().int(),
}).transform((o) => ({
utworzono: o.utworzono,
kwota: o.kwotaGroszy / 100,
}))Po takim przekształceniu typ wejścia i typ wyjścia są różne, i biblioteka rozróżnia je osobnymi narzędziami. To rozróżnienie bywa mylące, dopóki nie trafi się na przypadek, w którym ma znaczenie: opis danych wysyłanych na serwer potrzebuje typu wejścia, a kod pracujący na wyniku typu wyjścia.
Typowe zastosowanie to zamiana łańcuchów znaków na daty i liczby. Odpowiedź z interfejsu przychodzi w formacie tekstowym, bo tak działa zapis danych, i przekształcenie tego raz na granicy jest lepsze niż konwertowanie w piętnastu miejscach.
Warto natomiast uważać na przekształcenia zmieniające sens danych. Schemat, który przy okazji walidacji zaokrągla kwoty albo obcina łańcuchy, ukrywa logikę biznesową w miejscu, gdzie nikt jej nie szuka. Sprawdzanie i przeliczanie to dwie różne odpowiedzialności i mieszanie ich mści się przy pierwszym błędzie w wyliczeniach.
Osobną możliwością jest sprawdzanie warunków między polami. Data zakończenia późniejsza od daty rozpoczęcia albo hasło zgodne z powtórzeniem to reguły dotyczące całego obiektu, nie pojedynczego pola, i opisuje się je dodatkową kontrolą na poziomie obiektu razem ze wskazaniem, przy którym polu pokazać komunikat.
Komunikaty błędów i wielojęzyczność
Domyślne komunikaty są angielskie i techniczne, więc pokazanie ich użytkownikowi wprost jest błędem, który widać w każdej aplikacji budowanej w pośpiechu.
Są trzy drogi. Można podać własny komunikat przy każdej regule, co daje pełną kontrolę i najwięcej pisania. Można ustawić jeden komunikat na cały schemat, co wystarcza przy polach, gdzie i tak chodzi o „to pole jest wymagane". Można też podmienić komunikaty globalnie, korzystając z wbudowanego mechanizmu tłumaczeń, co jest właściwą drogą przy aplikacji wielojęzycznej.
Ostatnia opcja bywa pomijana, bo wymaga jednorazowej konfiguracji, a potem działa wszędzie. Warto ją ustawić na początku projektu, zanim w kodzie pojawi się sto ręcznie wpisanych komunikatów po polsku, których nikt później nie przetłumaczy.
Przy formularzach w Reakcie komunikaty wracają powiązane ze ścieżką do pola, więc pokazanie ich przy właściwym wejściu nie wymaga mapowania. Przy strukturach zagnieżdżonych ta ścieżka jest tablicą, a nie łańcuchem, i to jest szczegół, o który potyka się większość osób przy pierwszym formularzu z listą pozycji.
Zod kontra alternatywy
| Rozwiązanie | Rozmiar | Zapis | Kiedy wybrać |
|---|---|---|---|
| Zod | Średni, mini znacznie mniejszy | Metody łańcuchowe | Domyślny wybór w projekcie TypeScript |
| Valibot | Bardzo mały | Funkcje składane | Kod w przeglądarce, walka o kilobajty |
| Biblioteki oparte o klasy | Duży | Dekoratory | Zaplecze z wstrzykiwaniem zależności |
| Ręczne sprawdzanie | Zero | Zwykły kod | Jedno pole, jeden format |
Drugi wiersz jest najpoważniejszym konkurentem i różnica między nim a wariantem mini stopniała na tyle, że przy nowym projekcie warto zmierzyć oba na własnym kodzie zamiast wierzyć zestawieniom.
Ostatni wiersz warto rozważyć uczciwie. Sprawdzenie, czy pole zawiera liczbę większą od zera, nie wymaga biblioteki, a schemat opisujący jeden formularz z trzema polami bywa dłuższy niż kod, który zastąpił. Wartość pojawia się przy wyprowadzaniu typów i przy strukturach zagnieżdżonych.
Unie rozróżniane i kształty warunkowe
Obiekt o stałym zestawie pól to najprostszy przypadek, a większość realnych danych taka nie jest.
Odpowiedź z interfejsu bywa albo wynikiem, albo błędem. Zdarzenie w systemie ma inne pola zależnie od typu. Formularz pokazuje inne wejścia po wyborze opcji. We wszystkich tych przypadkach opis jednym obiektem z polami opcjonalnymi jest kłamstwem, bo pozwala na kombinacje, które nie mają prawa istnieć.
Właściwym narzędziem jest unia rozróżniana po jednym polu. Podajesz nazwę pola decydującego i listę wariantów, a biblioteka sprawdza wyłącznie ten wariant, który pasuje. Zysk jest podwójny: sprawdzanie działa szybciej, bo nie próbuje wszystkich możliwości po kolei, a komunikat błędu wskazuje konkretny problem zamiast zwracać listę niepowodzeń ze wszystkich wariantów naraz.
Wyprowadzony typ jest przy tym unią rozróżnianą w rozumieniu kompilatora, więc po sprawdzeniu pola decydującego pozostałe pola zawężają się same. To ten sam mechanizm, który sprawia, że kod obsługujący odpowiedź nie musi używać rzutowania.
Warto pamiętać o jednej rzeczy przy strukturach rekurencyjnych, na przykład drzewie kategorii. Typ trzeba wtedy zadeklarować ręcznie i przekazać go bibliotece, bo automatyczne wyprowadzenie nie radzi sobie z odwołaniem do samego siebie. To jedno z niewielu miejsc, w których ręczna adnotacja jest konieczna, a nie zbędna.
Praca z resztą stosu
Największa siła tej biblioteki bierze się z tego, że inne narzędzia potrafią z niej czytać.
Przy formularzach schemat służy jednocześnie do sprawdzania pól i do typowania wartości, więc nie deklarujesz kształtu dwa razy. Komunikaty błędów wracają powiązane z konkretnymi polami, co pozwala pokazać je przy właściwych wejściach bez ręcznego mapowania.
Przy tRPC schemat wejścia procedury pełni podwójną rolę: odrzuca niepoprawne dane i wyprowadza typ argumentu, który widzi klient. To jest ten sam mechanizm co przy formularzach, tylko rozciągnięty przez sieć.
Przy pracy z modelami językowymi schemat opisuje oczekiwaną strukturę odpowiedzi i pozwala odrzucić wynik, który jej nie spełnia. To jest miejsce, w którym walidacja w czasie działania jest niezbędna, bo model potrafi zwrócić coś prawie poprawnego i typ tego nie wychwyci.
Warto też wiedzieć, że schemat da się zamienić na opis w standardowym formacie i odwrotnie. Przy generowaniu dokumentacji interfejsu albo przy pracy z narzędziami spoza tego ekosystemu oszczędza to utrzymywania dwóch opisów tej samej rzeczy.
Typowe błędy
Pierwszy to używanie parse zamiast safeParse przy danych od użytkownika. Rzucony wyjątek zamienia niepoprawny formularz w błąd serwera.
Drugi to walidowanie tych samych danych wielokrotnie w środku aplikacji. Po sprawdzeniu na granicy dane mają typ i dalsze sprawdzanie jest kosztem bez zwrotu.
Trzeci to pominięcie sprawdzenia zmiennych środowiskowych. Brakujący klucz ujawnia się wtedy przy pierwszym żądaniu, które go potrzebował, zamiast przy starcie.
Czwarty to niezauważenie zmiany zachowania wartości domyślnych w wersji czwartej. Kod kompiluje się bez ostrzeżenia i działa inaczej.
Piąty to opisywanie schematem wnętrza aplikacji zamiast jej granic. Rośnie wtedy kod i czas wykonania, a bezpieczeństwo nie rośnie.
Szósty to importowanie pełnego wariantu w kodzie wysyłanym do przeglądarki, gdy wystarczy wariant funkcyjny. Przy prostym formularzu różnica w rozmiarze paczki jest zauważalna.
Siódmy to opisywanie danych o zmiennym kształcie jednym obiektem z polami opcjonalnymi. Typ dopuszcza wtedy kombinacje, które nie mają prawa wystąpić, a kod musi je obsłużyć albo udawać, że nie istnieją.
FAQ
Czym różni się Zod 4 od poprzedniej wersji?
Trzema zmianami zachowania i nowym kształtem pakietu. Metoda łącząca schematy została oznaczona jako przestarzała na rzecz rozszerzania, choć nadal działa, obsługa błędów została ujednolicona, a wartości domyślne stosują się teraz również w polach opcjonalnych i są zwracane bez przepuszczania przez schemat. Rdzeń biblioteki jest przy tym ponad dwukrotnie mniejszy.
Jak przeprowadzić migrację bez przestoju?
Plik po pliku. Poprzednia wersja jest dostępna pod osobną ścieżką importu, więc obie mogą działać w jednym projekcie równolegle. Największą uwagę poświęć miejscom z wartościami domyślnymi, bo tam zmiana nie zgłosi się jako błąd kompilacji.
Czym jest wariant mini?
Tym samym zestawem walidatorów wystawionym jako osobne funkcje zamiast metod łańcuchowych. Pozwala narzędziu budującemu usunąć wszystko, czego nie użyłeś, więc opłaca się w kodzie wysyłanym do przeglądarki. Po stronie serwera wygodniejszy jest wariant podstawowy.
Czy Zod spowalnia aplikację?
Sprawdzanie kosztuje czas, ale wykonuje się na granicach systemu, więc zwykle raz na żądanie. Problem pojawia się przy walidowaniu tych samych danych wielokrotnie wewnątrz aplikacji albo przy sprawdzaniu bardzo dużych tablic w pętli obsługującej żądanie.
Czy potrzebuję Zoda, skoro mam typy?
Tak, bo typy znikają przy kompilacji. Odpowiedź z serwera opisana jako obiekt o określonym kształcie może w rzeczywistości być czymkolwiek, a kompilator w to uwierzy. Walidacja w czasie działania jest jedynym sprawdzeniem, które faktycznie się odbywa.
Pełna dokumentacja stoi na stronie projektu, a opis zmian w czwartej wersji w notatkach wydania.