12k
All articles

Tornando a validação do Zod mais rápida com schemas compilados

Veja como esquemas compilados do Zod aceleram a validação, quando o ganho importa e como lidar com custos, esquemas incompatíveis e restrições de CSP.

OpenReplay Team
OpenReplay Team
Tornando a validação do Zod mais rápida com schemas compilados

A partir do Zod 4.5.0, z.compile() transforma um schema em um validador JavaScript especializado. Ele valida entradas válidas mais rapidamente e retorna exatamente os mesmos resultados e erros que o schema não compilado.

Se o seu handler faz o parse do mesmo corpo de requisição grande milhares de vezes por minuto, uma camada de validação que percorre a árvore do schema a cada chamada acaba aparecendo nos seus perfis de CPU. O release do Zod 4.5 trouxe uma solução para isso. Este artigo dá continuidade ao guia da OpenReplay sobre validação de dados em TypeScript com Zod. Ele mostra como ativar a compilação, quanto ela custa, em que situações o ganho de desempenho é grande o suficiente para fazer diferença e o que acontece sob uma Content Security Policy restritiva ou em runtimes como o Cloudflare Workers.

Principais conclusões

  • z.compile(schema) gera um validador sem loops nem percurso com ramificações e o executa via new Function(). Entradas válidas são verificadas por uma sequência simples de testes typeof, em vez de um percurso pela árvore do schema.
  • Entradas inválidas quase não ganham nada, porque um parse que falha executa primeiro o fast path e, em seguida, o parser padrão completo para montar os erros.
  • No benchmark de loop fechado do próprio Zod, objetos compilados com 5, 10, 20 e 50 chaves são executados 1,8x, 2,2x, 5,0x e 10,2x mais rápido, ou seja, schemas pequenos ganham pouco.
  • O compilador adiciona cerca de 7 KB (gzip) a qualquer bundle que chame z.compile() ou importe zod/compile.
  • { strict: true } faz com que schemas não compiláveis lancem ZodCompileAsyncError ou ZodCompileUnsupportedError, em vez de fazer fallback silenciosamente.

Como funcionam os schemas compilados do Zod?

Um schema compilado do Zod valida a entrada com código gerado, em vez de percorrer a árvore do schema. O Zod lê o schema inteiro uma única vez, gera um trecho curto de JavaScript sem nenhum loop e o transforma em uma função com new Function(). A partir daí, a entrada válida é verificada lendo cada propriedade e testando seu typeof, uma linha após a outra. Se o fast path rejeitar uma entrada, o Zod executa o parser padrão sobre ela. É por isso que um schema compilado reporta os mesmos issues e mensagens de erro que o original. Você continua usando .parse(), .safeParse() e os mesmos tipos inferidos.

Ativando a compilação

Há duas formas de ativar o recurso: compilar schemas específicos com z.compile() ou compilar todos os schemas da aplicação importando zod/compile globalmente. A compilação por schema oferece controle preciso sobre os caminhos críticos (hot paths). O modo global exige apenas uma linha.

Por schema com z.compile()

z.compile() retorna um novo schema compilado. O schema passado como argumento permanece inalterado. Qualquer método que construa um novo schema a partir de um compilado, como .refine(), .extend() ou .optional(), retorna um schema não compilado. Monte o schema final primeiro e compile-o por último:

import * as z from "zod";

const Base = z.object({
  id: z.string(),
  type: z.string(),
  createdAt: z.number(),
});

const notInFuture = (e: { createdAt: number }) => e.createdAt <= Date.now();

// ❌ .refine() returns a new schema, and it is not compiled
const Wrong = z.compile(Base).refine(notInFuture);

// ✅ finish the schema, then compile it
const WebhookEvent = z.compile(Base.refine(notInFuture));

const result = WebhookEvent.safeParse(payload);

Nada em tempo de execução indica que Wrong não está compilado. Ele valida corretamente, apenas de forma mais lenta. A opção strict (abordada mais adiante) também não detecta esse caso, pois ela só lança um erro quando um schema não pode ser compilado de forma alguma. O hábito mais seguro é fazer de z.compile() a última chamada da cadeia.

Globalmente com zod/compile

Com a compilação por padrão, o Zod compila cada schema criado após o import. Isso é feito de forma lazy, no primeiro parse de cada schema:

import "zod/compile"; // must run before any module that defines schemas
import * as z from "zod";

const User = z.object({ name: z.string() });
User.parse({ name: "ok" }); // compiled on first parse

É fácil errar a ordem de carregamento em ESM, então deixe o runtime carregar o módulo primeiro:

node --import zod/compile app.js    # ESM
node --require zod/compile app.cjs  # CommonJS

Quem usa Bun pode listar zod/compile em preload no bunfig.toml. O modo global foi pensado para aplicações. Bibliotecas não devem ativá-lo para seus consumidores.

Quanto custa a compilação no Zod?

A compilação de schemas no Zod tem três custos e faz pouco pelas entradas inválidas. Um parse rejeitado executa o fast path, falha e depois executa o parser padrão completo, que consome quase todo o tempo. Os três custos são:

  1. Trabalho de compilação único para cada schema, realizado quando o schema é compilado ou, no modo global, no seu primeiro parse.
  2. Tamanho do bundle. O compilador adiciona cerca de 7 KB com gzip (28 KB minificado). A tabela do próprio Zod indica que um schema de objeto com quatro chaves passa de 24,1 KB para 31,1 KB (gzip) com o compilador. No Zod Mini, o salto é maior: de 4,6 KB para 13,2 KB. Bundles que nunca chamam z.compile() nem importam zod/compile eliminam o compilador por completo durante o tree-shaking.
  3. Execução dupla em caso de falha. Um refinement ou transform é executado uma única vez quando a entrada é válida, mas pode ser executado duas vezes quando não é. Um refinement que registra logs, incrementa contadores ou grava dados fará isso duas vezes para um webhook rejeitado.

Em uma fronteira de requisições com tráfego constante, o trabalho único se paga rapidamente. Em um script avulso ou em uma CLI que faz o parse de um único arquivo de configuração, o custo da compilação e os bytes extras talvez nunca sejam compensados. Tráfego dominado por entradas inválidas, como sondagens de bots ou assinaturas de webhook forjadas, também recupera pouco.

Onde os ganhos de desempenho do Zod aparecem?

Os ganhos de desempenho da compilação no Zod aumentam com o tamanho do schema. Objetos e tuplas largos são os que mais se beneficiam, porque o código gerado verifica cada chave em sequência, sem o loop por chave do parser padrão. O benchmark do próprio Zod mede cada schema isoladamente, repetidas vezes, em um loop fechado. O parser padrão tem seu melhor desempenho nesse cenário, por isso os ganhos aqui são menores do que no gráfico principal no topo da mesma página.

SchemaGanho de velocidade
Objeto, 5 chaves1,8x
Objeto, 10 chaves2,2x
Objeto, 20 chaves5,0x
Objeto, 50 chaves10,2x
Tupla, 1 item2,2x
Tupla, 3 itens2,5x
Tupla, 5 itens3,0x
Tupla, 10 itens3,7x

Um formulário de login com três campos não vai perceber a diferença. Um payload de evento com 50 chaves processado a cada requisição, sim. Números maiores, como o “até 44x” (e até 46x em entradas rejeitadas) citado para o zod-compiler, vêm de ferramentas de terceiros independentes que geram validadores em tempo de build. Eles não descrevem o compilador em runtime nativo do Zod.

Quais schemas do Zod não são compilados?

Alguns recursos de schema do Zod não podem ser compilados. Quando z.compile() encontra um deles, não lança erro: simplesmente devolve, sem alarde, o schema que você passou, sem compilação. A lista de recursos não suportados determina se você perde o schema inteiro ou apenas um filho:

RecursoEfeito
Refinements, transforms ou checks assíncronos em qualquer ponto da árvoreO schema inteiro faz fallback
.catch() com callback (.catch(value) é compilado)O schema inteiro faz fallback
Union com um membro não suportadoO schema inteiro faz fallback
z.xor(), schemas recursivos, z.coerce.*, checks com when personalizadoNão compilado
Filho não suportado dentro de object, array, tuple, record ou intersectionApenas esse filho usa o parser padrão

A compilação nunca se aplica a z.encode() nem ao parse assíncrono. Ambos sempre passam pelo parser padrão. Se seus handlers chamam safeParseAsync, compilar o schema não traz nenhum benefício.

Para evitar que uma alteração futura desative silenciosamente a compilação em um hot path, compile no CI com { strict: true }:

import { test } from "node:test";
import assert from "node:assert/strict";
import * as z from "zod";
import { WebhookEvent, OrderBody } from "../src/schemas.js";

test("hot-path schemas compile", () => {
  for (const schema of [WebhookEvent, OrderBody]) {
    assert.doesNotThrow(() => z.compile(schema, { strict: true }));
  }
});

test("async refinements are rejected under strict", () => {
  const Handle = z.string().refine(async (v) => v.length > 2);
  assert.throws(() => z.compile(Handle, { strict: true })); // ZodCompileAsyncError
});

Com strict ativado, um schema assíncrono lança ZodCompileAsyncError, e qualquer outro schema que o Zod não consiga compilar lança ZodCompileUnsupportedError. Sem strict, nenhum dos dois erros é lançado.

O z.compile() funciona sob CSP e no Cloudflare Workers?

z.compile() falha de forma segura, mas não oferece nenhum benefício onde a geração dinâmica de código é proibida. new Function() é bloqueado em qualquer página cuja Content Security Policy não inclua 'unsafe-eval' em script-src (ou em default-src, quando não há script-src). O Zod também cita o Cloudflare Workers como um ambiente em que new Function() é bloqueado. Se você definir jitless, o modo global se desativa automaticamente:

// config.ts: import this module before any module that defines schemas
import * as z from "zod";

z.config({ jitless: true });

Chamar z.compile() diretamente é diferente. O Zod interpreta isso como uma solicitação explícita, então tenta gerar código mesmo com jitless ativado. Se o ambiente bloquear new Function, você simplesmente recebe o schema de volta sem compilação. A validação continua funcionando. No entanto, você terá entregado cerca de 7 KB (gzip) de um compilador que nunca poderá ser executado; portanto, deixe zod/compile e z.compile() de fora dos builds voltados para esses ambientes.

Conclusão

A compilação torna a verificação de entradas válidas mais rápida sem alterar resultados nem erros, e o ganho aumenta com a largura do schema. Comece fazendo o profiling da sua fronteira de requisições. Compile seus schemas mais largos e mais utilizados por último na cadeia de construção e adicione um teste com strict para garantir que continuem compiláveis. Mantenha o compilador fora dos builds destinados a runtimes e políticas de CSP que bloqueiam new Function. Instale o release atual do Zod 4 em vez de fixar a versão 4.5.0, para receber as correções do compilador.

Perguntas frequentes

Existe uma forma mais rápida do que safeParse para rejeitar entradas inválidas no Zod?

Sim. O Zod 4.6 adicionou .validate(), que apenas informa se a entrada é válida. Ele não constrói um ZodError, então rejeitar entradas inválidas custa muito pouco. Em um schema compilado com entrada inválida, ele pode ser até 35x mais rápido do que .safeParse().success. Ele também faz o narrowing do tipo: quando retorna true, o TypeScript trata o valor como o tipo de entrada do schema. Use-o quando precisar apenas de uma resposta sim ou não e mantenha .safeParse() quando precisar dos detalhes do erro.

Posso usar um parser do Zod pré-compilado em ambientes que bloqueiam new Function?

Sim, a partir do Zod 4.6. z.compile() faz duas coisas: gera um parser e o anexa ao schema. z.withParser() faz apenas a segunda. Você fornece um parser gerado em outro lugar, por exemplo, por uma etapa de build ou por um compilador nativo, e ele anexa esse parser seguindo as mesmas regras de z.compile(). Como o código gerado é distribuído como JavaScript comum, o runtime nunca precisa de new Function.

Qual é a diferença entre z.compile() e zod-compiler?

z.compile() é nativo do Zod e gera validadores em tempo de execução com new Function(), dentro do seu processo. O zod-compiler (gajus/zod-compiler) é uma ferramenta de terceiros independente que gera validadores em tempo de build, por meio de plugins de bundler para Vite, webpack, esbuild, Rollup e outros, ou por meio de uma CLI. A saída gerada em tempo de build é código comum, sem eval, portanto regras de CSP não conseguem desativá-la. Seu modo de runtime opcional, porém, usa new Function. O release atual do zod-compiler exige o Zod 4.5 ou superior, e sua linha 1.x cobre do Zod 4.0 ao 4.4.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.