Jak błąd typu getter po cichu kosztował Zoda trzykrotnie gorszą wydajność w czasie działania
Wgląd w to, jak mechanizm generowania getterów w CommonJS w TypeScript blokował wbudowywanie kodu V8 w Zod, oraz co zmieniło się podczas całkowitej przebudowy Zod 4.
Błąd: Gettery są niewidoczne dla JIT
Gdy TypeScript kompiluje instrukcję re-export, taką jak export * from './schemas', nie po prostu kopiuje wartości. Zamiast tego generuje getter dla każdego eksportowanego elementu — małą funkcję, która jest wykonywana za każdym razem, gdy odczytuje się daną właściwość, zamiast udostępniać prostą, statyczną właściwość przechowującą wartość bezpośrednio.
Zazwyczaj jest to szczegół implementacji, którego nikt nie zauważa. W przypadku Zod miało to duże znaczenie: 252 z 255 eksportów w punkcie wejścia CommonJS w Zod 4.5 zostały zaimplementowane jako gettery. JIT V8 doskonale radzi sobie z wstawianiem kodu bezpośrednio – zastępuje wywołanie funkcji jej rzeczywistym ciałem, dzięki czemu silnik unika dodatkowych kosztów związanych z wywołaniem – ale tylko wtedy, gdy może zagwarantować, że funkcja docelowa jest stabilna i przewidywalna. Getter łamie tę gwarancję. V8 nie ma sposobu, aby zobaczyć stałą, niezmienianą funkcję ukrytą za getterem, więc nie może bezpiecznie wstawić kodu do żadnego elementu dostępnego w ten sposób.
Rozwiązanie w Zod 4.6 brzmi niemal zbyt prosto: emitować zwykłe właściwości zamiast getterów oraz zamrozić powstały obiekt eksportów, aby V8 wiedział, że nic się w nim nigdy nie zmieni. Oto jak to wygląda w praktyce:
// CommonJS require — this is the path that was affected
const { z } = require("zod");
const CompiledPlayer = z.compile(Player);
z.validate(CompiledPlayer, data);
// ~3x faster in Zod 4.6 than the identical call in Zod 4.
Warto być precyzyjnym co do tego, na jak wąski zakres faktycznie się ogranicza ta poprawka, ponieważ łatwo jest przesadzić co do jej zasięgu. Dotknięte zostały jedynie wywołania kierowane przez obiekt namespace — takie jak z.validate(...) lub z.compile(...) — i to tylko przy użyciu require(). Bezpośrednie wywołanie metody na instancji schematu, np. Player.safeParse(data), w ogóle nie wpływa na obiekt exports, więc ten wzorzec nigdy nie został dotknięty. Budowa w formacie ESM pozostała całkowicie nietknięta; była to ściśle mówiąc cecha specyficzna dla mechanizmu re-exports w CommonJS.
Kluczowa lekcja wykracza daleko poza sam Zod: forma wynikowa generowana przez kompilator ma rzeczywiste konsekwencje podczas wykonywania, które nie mają nic wspólnego z logiką, którą faktycznie napisałeś. Nikt używający Zod 4.5 nie pisał gorszego kodu w porównaniu z osobą używającą wersji 4.6 — identyczna funkcja z.validate() działała po prostu szybciej, ponieważ oddzielne narzędzie, kompilator TypeScript, w sposób przypadkowy sformatowało swoje wyniki inaczej.
Za tym kryje się większa przebudowa
To rozwiązanie jest tylko małą częścią znacznie większych działań: Zod 4, stabilny od 2025 roku, to całkowite przepisanie od zera, a uzyskane przy tym szybkości są same w sobie znaczne. Niezależne testy wykazały, że przetwarzanie zwykłych ciągów znaków odbywa się mniej więcej czternaście razy szybciej, tablice około siedem razy szybciej, a przetwarzanie obiektów blisko sześć i pół raza szybciej, przy porównaniu z Zod 3. Jednak zmiana, która najprawdopodobniej wpłynie na twoją codzienną pracę, w ogóle nie dotyczy szybkości działania: liczba instancji kompilatora TypeScript dla typowego schematu spadła z ponad 25 000 do około 175 — co tłumaczy, dlaczego edytory i narzędzia do sprawdzania typów wcześniej miewały problemy z dużymi kodami opartymi na Zod, a obecnie już tak nie jest.
W sytuacjach, gdzie rozmiar pakietu ma kluczowe znaczenie — funkcje na brzegu, widgety po stronie klienta — Zod Mini oferuje ten sam zestaw walidatorów poprzez całkowicie możliwą do optymalizacji funkcjonalną interfejs, zamiast znajomego stylu łańcuchowych metod Zod:
// Standard Zod — method chaining
import * as z from "zod";
const User = z.object({ name: z.string(), age: z.number() });
// Zod Mini - same validators, functional style, smaller bundle
import * as z from "zod/mini";
const User = z.object({ name: z.string(), age: z.number() });
Co tak naprawdę zmieniło się w API
To jest część, w której proste polecenie npm install zod@^4 może potajemnie uszkodzić istniejący kod, dlatego warto przejrzeć każdą zmianę bezpośrednio, zamiast polegać na streszczeniu z changeloga.
Walidatory formatu ciągów znaków stały się funkcjami najwyższego poziomu, możliwymi do optymalizacji:
// Zod 3 style — deprecated, but still works
const schema = z.string().email();
// Zod 4 - the new standard
const schema = z.email();
const id = z.uuid();
const site = z.url();
Cztery oddzielne mechanizmy do personalizacji komunikatów o błędach połączono w jedną opcję:
// ❌ Zod 3 — three different mechanisms
const schema = z.string({
required_error: "Name is required",
invalid_type_error: "Name must be a string",
});
const age = z.number({
errorMap: (issue, ctx) => {
if (issue.code === "too_small") return { message: "Must be 18+" };
return { message: ctx.defaultError };
},
});
// ✅ Zod 4 - one parameter, string or function
const schema = z.string({ error: "Name is required" });
const age = z.number({
error: (issue) => {
if (issue.code === "too_small") return "Must be 18+";
return "Invalid age";
},
});
Formatowanie błędów zostało oddzielone od obiektu błędu i przekształcone w samodzielne funkcje pomocnicze:
const result = User.safeParse(input);
if (!result.success) {
result.error.issues; // the raw array - was .errors in Zod 3
z.treeifyError(result.error); // nested shape, replaces .format()
z.flattenError(result.error); // { formErrors, fieldErrors }, replaces .flatten()
z.prettifyError(result.error); // human-readable string, great for logs
}
Typowy obsługiwacz ścieżki API napisany z użyciem Zod 4 wygląda mniej więcej w ten sposób:
app.post("/users", (req, res) => {
const result = User.safeParse(req.body);
if (!result.success) {
const { fieldErrors } = z.flattenError(result.error);
return res.status(400).json({ errors: fieldErrors });
}
// result.data is fully typed here
createUser(result.data);
});
Subtelna pułapka, którą warto wyodrębnić
Dwie zmiany wprowadzone w Zod 4 należą do specjalnej kategorii zagrożeń: umykają podczas przeglądania kodu bez wywoływania żadnych ostrzeżeń, a potem pojawiają się jako błędy w środowisku produkcyjnym kilka tygodni później. Obie zasługują na osobne wskazanie, zamiast być pogrzebane w liście.
ZodError.errors zniknął, zastąpiony przez .issues. Jeśli jakaś z istniejących logik obsługi błędów nadal używa error.errors, nic się nie wydarzy. Po prostu w cichosti zwraca wartość undefined. Taki błąd przechodzi bez problemu przez każdy zestaw testów, który nie sprawdza wyraźnie tej konkretnej właściwości, i staje się widoczny dopiero wtedy, gdy rzeczywy użytkownik napotka go w środowisku produkcyjnym.
Przynależność priorytetu komunikatów o błędach kontekstowych została odwrócona. W wersji Zod 3 komunikat o błędzie podany w momencie analizy miał wyższy priorytet niż ten zdefiniowany w samej schemacie. W wersji Zod 4 ten priorytet został odwrócony: teraz przeważa komunikat na poziomie schematu.
const mySchema = z.string({ error: () => "Schema-level error" });
// Zod 3: this override wins → "Contextual error"
// Zod 4: the schema-level error wins instead → "Schema-level error"
mySchema.parse(12, { error: () => "Contextual error" });
Nic się nie zmienia w miejscu wywołania, a mimo to ten sam kod zwraca inny komunikat w zależności wyłącznie od zainstalowanej głównej wersji — jest to odwrócenie zachowania ukryte za pozornie prostą aktualizacją nazwy.
Prawdziwy obraz konkurencji
Jest kuszące traktowanie poprawek w wersji 4.6 oraz szerszej przepracowania jako dowodu na to, że Zod teraz bezkonkurencyjnie przewyższa wszystkie inne biblioteki walidacji, ale rzeczywiste liczby wymagają bardziej umiarkowanego wniosku. Przeprowadzając milion operacji walidacji na zagnieżdżonym obiekcie składającym się z ośmiu pól na maszynie M3 Pro, ArkType kończy zadanie w około 820 ms, Valibot – w około 1 140 ms, a Zod 4 – w około 1 380 ms. Dla porównania, Zod 3 potrzebował około 4 200 ms na wykonywanie tych samych zadań, więc to przepracowanie stanowi rzeczywisty, znaczny postęp w porównaniu z jego poprzednikiem, nawet jeśli nie jest najlepsze pod względem liczby obsługiwanych pól. Jeśli chodzi o rozmiar pliku, Valibot utrzymuje dużą przewagę: typowe schemat formy logowania waży około 1,37 KB przy użyciu Valibot, w porównaniu z około 17,7 KB przy standardowym Zod, a nawet blisko 7 KB przy użyciu Zod Mini.
Bardziej przydatnym wnioskiem z tych samych danych jest to, że przy przepustowości jednego miliona walidacji na sekundę — co znacznie przewyższa potrzeby jakiegokolwiek realistycznego punktu końcowego API — różnica wydajności między tymi trzema bibliotekami objawia się zaledwie kilkuset milisekundami w skali miliona wywołań. Ta różnica nie będzie zauważalna w normalnym ruchu produkcyjnym. W przypadku usług Node.js oraz baz kodu opartych w dużej mierze na tRPC, głębsze wsparcie ekosystemu Zod oraz jego charakterystyczny styl metod łańcuchowych zazwyczaj mają większe znaczenie w codziennym użyciu niż to, która biblioteka wygrywa w syntetycznych testach wydajności. W sytuacjach, gdy rzeczywiście ograniczeniem jest rozmiar pliku — funkcje brzegowe lub walidatory wysyłane do klienta — przewaga Valibot pod względem rozmiaru jest tym czynnikiem, który faktycznie decyduje o wyniku, niezależnie od szybkości walidacji przez te biblioteki.
Praktyczne wskazówki dotyczące migracji
Najpierw upewnij się, że używasz TypeScript 5.5 lub nowszej wersji, ponieważ Zod 4 tego wymaga. Metody przestarzałe przeniesione z Zod 3 nadal działają, ale wyświetlają jedynie ostrzeżenia w czasie wykonywania, i to właśnie dlatego większość zespołów przeprowadza migrację stopniowo, plik po pliku, zamiast podejmować ryzykowną próbę całkowitej zmiany jednocześnie. Najważniejszym krokiem do podjęcia jako pierwszym jest przeszukanie całego kodu w poszukiwaniu wyrażeń .errors, .format() oraz .flatten() używanych z obiektami błędów Zod, ponieważ to właśnie te zmiany powodują problemy w sposób niesłyszalny, a nie głośny. A jeśli twój projekt już korzysta z Zod 4, ale działa poprzez ścieżkę CommonJS require() w Node – co jest nadal powszechne w konfiguracjach backendowych, nawet w kodach opartych na ESM – aktualizacja do wersji 4.6 to praktycznie bezkosztowe poprawienie wydajności, ponieważ naprawa nie wymaga żadnych zmian w twoim własnym kodzie.
Rzeczywisty wniosek
Historia naprawy wersji 4.6 jest prosta, ale lekcja, jaką z niej wynika, jest ważniejsza: to, co faktycznie działa w środowisku produkcyjnym, jest określane jedynie po części przez napisany kod. Druga połowa zależy od tego, co kompilator i narzędzie do pakowania wybiorą, aby zaimplementować w jego imieniu, a ta implementacja ma własne zachowanie pod względem wydajności, które nie ma nic wspólnego z tym, jak starannie napisana była nasza własna logika. Przez większość czasu można bezpiecznie całkowicie ignorować tę warstwę. Ale od czasu do czasu — jak w przypadku 252 metod getter, które przez ponad rok cicho blokowały funkcje inliner V8 — warto pamiętać, że „mój kod jest poprawny” i „mój kod kompiluje się do czegoś szybkiego” to dwa odrębne stwierdzenia. Drugie z nich warto od czasu do czasu sprawdzić, nawet jeśli nic z tego, co zrobiliśmy, tak naprawdę nie było błędne.
Pozycje pokrewne
- Dlaczego mapowanie w TypeScript oparte na refleksji niszczy wydajność V8 — Wyjaśnia, w jaki sposób ukryte klasy i pamięci cache w V8 pogarszają się pod wpływem mapowania obiektów opartego na refleksji, oraz jak funkcje monomorficzne skompilowane za pomocą JIT przywracają szybkość w API NestJS.
- Rola mostu w TypeScript 6 na drodze do natywnego kompilatora TS 7 — Dowiedz się, w jaki sposób TypeScript 6 aktualizuje domyślne konfiguracje, mechanizmy rozwiązywania modułów oraz składnię importów, aby przygotować bazy kodowe na szybszy, oparty na Go kompilator TypeScript 7.