Validation com schema
TL;DR
Schema-first é o padrão pragmático para validation em Node moderno.
zodune schema, runtime validation e type inference; Fastify usa JSON Schema nativo; NestJS usaValidationPipecomclass-validatorou wrappers zod. O objetivo é evitar validação manual espalhada. O ponto de maturidade é validar toda entrada externa — não apenas o body, mas também params, query, headers, webhooks e mensagens de fila — antes de qualquer lógica de negócio.
O que é
Validation com schema define o contrato de input em um objeto reutilizável. Esse contrato valida dados em runtime e, dependendo da ferramenta, também gera tipos TypeScript e documentação.
A alternativa sem schema é validação manual: if (!body.email || !body.email.includes("@")). Esse padrão cresce linearmente com o número de campos, duplica em cada endpoint e gera mensagens de erro inconsistentes. Um schema centraliza a regra, permite reuso e torna o contrato visível para quem lê o código.
Por que importa
Toda entrada externa é unknown: body, query, params, headers, webhooks, mensagens de fila. Sem schema, validação vira if espalhado em controller. Com schema, validação fica centralizada, testável e reaproveitável.
A fronteira que importa não é só a HTTP: uma mensagem Kafka, um evento SQS, um payload de webhook e uma variável de ambiente são entradas tão externas quanto um body de POST. Aplicar schema a todas essas fronteiras é o que separa uma aplicação defensiva de uma que falha silenciosamente com dados malformados.
Como funciona
import { z } from "zod";
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).optional(),
}).strict();
type CreateUser = z.infer<typeof CreateUserSchema>;
app.post("/users", (req, res) => {
const data: CreateUser = CreateUserSchema.parse(req.body);
res.status(201).json(data);
});app.post(
"/users",
{
schema: {
body: {
type: "object",
required: ["name", "email"],
additionalProperties: false,
properties: {
name: { type: "string", minLength: 1, maxLength: 100 },
email: { type: "string", format: "email" },
age: { type: "integer", minimum: 0 },
},
},
},
},
async (req) => req.body,
);import { IsEmail, IsInt, IsOptional, IsString, MaxLength, Min, MinLength } from "class-validator";
export class CreateUserDto {
@IsString()
@MinLength(1)
@MaxLength(100)
name!: string;
@IsEmail()
email!: string;
@IsOptional()
@IsInt()
@Min(0)
age?: number;
}app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);import { zValidator } from "@hono/zod-validator";
app.post("/users", zValidator("json", CreateUserSchema), (c) => {
const data = c.req.valid("json");
return c.json(data, 201);
});Fluxo de validação schema-first
flowchart LR ext([Entrada externa\nbody · query · params\nwebhook · fila]):::red ext --> schema{Schema\nvalidation}:::amber schema -->|inválido| err["ValidationError\n→ Problem Details 400"]:::red schema -->|válido| typed[Dado tipado\ninferido pelo schema]:::blue typed --> usecase[Use case\ndomínio]:::blue typed --> domain[Value object\ninvariantes fortes]:::blue classDef blue fill:#4A90D9,color:#fff,stroke:#4A90D9 classDef amber fill:#F5A623,color:#fff,stroke:#F5A623 classDef red fill:#D0021B,color:#fff,stroke:#D0021B
Schema de boundary protege a aplicação de dados externos; value objects protegem o domínio de invariantes quebradas internamente.
Casos práticos
Cenário 1: cadastro de usuário com separação de boundary e domínio
Uma API de cadastro precisa validar entrada HTTP, transformar o email para lowercase e garantir que o domínio receba um tipo com invariante forte.
import { z } from "zod";
// Schema de boundary: valida o que chega pelo HTTP.
const CreateUserInput = z.object({
name: z.string().trim().min(1, "Name is required").max(100, "Name too long"),
email: z.string().email("Invalid email format"),
locale: z.enum(["pt-BR", "en-US"]).default("en-US"),
}).strict();
type CreateUserInputType = z.infer<typeof CreateUserInput>;// Value object de domínio: encapsula invariante de email.
class Email {
private constructor(readonly value: string) {}
static parse(raw: string): Email {
const parsed = z.string().email().parse(raw);
return new Email(parsed.toLowerCase()); // normalização no domínio
}
equals(other: Email): boolean {
return this.value === other.value;
}
}// Controller: valida na boundary, entrega tipo forte ao use case.
app.post("/users", zValidator("json", CreateUserInput), async (c) => {
const input = c.req.valid("json"); // tipado como CreateUserInputType
// Use case recebe value object, não string crua.
const user = await createUserUseCase.execute({
name: input.name,
email: Email.parse(input.email),
locale: input.locale,
});
return c.json(user, 201);
});Schema de boundary e value object têm responsabilidades diferentes: o schema protege contra formato inválido externo; o value object encapsula invariante de negócio.
Cenário 2: validação de mensagem de fila Kafka com schema reutilizável
Uma aplicação que consome eventos de um tópico Kafka precisa validar cada mensagem antes de processar, com o mesmo rigor aplicado ao HTTP.
import { z } from "zod";
// Schema compartilhado entre producer e consumer.
const OrderPlacedEvent = z.object({
eventId: z.string().uuid(),
orderId: z.string().uuid(),
userId: z.string().uuid(),
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
unitPrice: z.number().positive(),
})).min(1, "Order must have at least one item"),
placedAt: z.string().datetime(),
totalAmount: z.number().positive(),
}).strict();
type OrderPlacedEventType = z.infer<typeof OrderPlacedEvent>;// Consumer: valida antes de processar.
async function handleOrderPlaced(rawMessage: unknown): Promise<void> {
// Mensagem de fila é unknown — pode vir corrompida ou de versão incompatível.
const result = OrderPlacedEvent.safeParse(rawMessage);
if (!result.success) {
logger.error({
errors: result.error.issues,
raw: rawMessage,
}, "Invalid OrderPlaced event — skipping to dead-letter queue");
await deadLetterQueue.send(rawMessage);
return; // não lança — evita retry infinito de mensagem inválida
}
const event: OrderPlacedEventType = result.data;
await fulfillmentService.process(event);
}// Teste: schema como documentação executável.
test("rejects event without items", () => {
const result = OrderPlacedEvent.safeParse({
eventId: randomUUID(),
orderId: randomUUID(),
userId: randomUUID(),
items: [], // array vazio deve falhar
placedAt: new Date().toISOString(),
totalAmount: 0,
});
expect(result.success).toBe(false);
});Note o uso de safeParse em vez de parse: em consumer de fila, lançar exceção para mensagem inválida pode gerar retry infinito. safeParse permite redirecionar para dead-letter queue de forma controlada.
Boundary-first validation
Valide em todas as fronteiras externas, não apenas em controllers HTTP.
const GithubWebhookSchema = z.object({
action: z.string(),
repository: z.object({ full_name: z.string() }),
sender: z.object({ login: z.string() }),
}).strict();
export async function handleGithubWebhook(raw: unknown) {
const event = GithubWebhookSchema.parse(raw);
return processGithubEvent(event);
}Esse mesmo padrão vale para Kafka, SQS, cron input, env vars e arquivos importados.
Params, query e headers
Body é só uma parte da entrada.
const Params = z.object({ id: z.string().uuid() });
const Query = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(25),
});
app.get("/users/:id", (req, res) => {
const params = Params.parse(req.params);
const query = Query.parse(req.query);
return users.findOne(params.id, query);
});z.coerce é útil para query string, mas deve ser usado deliberadamente. Coerção ampla demais pode esconder input ruim.
Input schema vs domain type
Nem todo schema de input é tipo de domínio. Input aceita strings, formatos externos e campos opcionais. Domínio pode exigir invariantes mais fortes.
const CreateUserInput = z.object({
name: z.string().min(1),
email: z.string().email(),
});
class Email {
private constructor(readonly value: string) {}
static parse(value: string) {
const email = z.string().email().parse(value);
return new Email(email.toLowerCase());
}
}Schema de boundary protege a aplicação; value objects protegem o domínio.
Versionamento de schema
APIs evoluem. Não altere schema de forma incompatível sem versionar contrato.
const CreateUserV1 = z.object({
name: z.string(),
email: z.string().email(),
});
const CreateUserV2 = CreateUserV1.extend({
locale: z.enum(["pt-BR", "en-US"]).default("en-US"),
});Escolha uma estratégia: path (/v1, /v2), header, media type ou compatibilidade aditiva. O pior cenário é mudar silenciosamente.
Erros de validação como Problem Details
Validation deve conversar com 08 - Error handling estruturado.
function toProblem(error: z.ZodError, instance: string) {
return {
type: "https://api.example.com/errors/validation",
title: "Validation Failed",
status: 400,
detail: "Request payload is invalid",
instance,
invalidParams: error.issues.map((issue) => ({
name: issue.path.join("."),
reason: issue.message,
})),
};
}Cliente precisa de erro parseável; dev precisa de log completo.
Checklist de code review
- Body, params, query e headers relevantes são validados?
- Schema é strict quando contrato exige?
- Coerção (
z.coerce, transform) é intencional? - Schema de input não foi confundido com entity de domínio?
- Erros de validation viram Problem Details estável?
- Schemas têm estratégia de versionamento?
- Mensagens de erro não vazam detalhes internos?
- Testes cobrem payload válido, inválido e campos extras?
Exercício de maturidade
Validação manual típica:
if (!body.email || !body.email.includes("@")) {
throw new BadRequestError("invalid email");
}Problemas:
- regra incompleta;
- mensagem inconsistente;
- tipo TypeScript não melhora;
- teste precisa cobrir cada if manual;
- controller cresce.
Schema-first:
const Email = z.string().email().transform((value) => value.toLowerCase());
const CreateUser = z.object({
name: z.string().trim().min(1).max(100),
email: Email,
}).strict();Agora o contrato é declarativo, reusável e testável.
Testes de schema
Schemas merecem teste quando são contrato público.
test("rejects unknown fields", () => {
expect(() => CreateUser.parse({
name: "Ada",
email: "ada@example.com",
admin: true,
})).toThrow();
});Esse teste protege contra regressão de .strict() removido por acidente.
Armadilhas comuns
Schema permissivo sem
.strict()aceita campos desconhecidosO que acontece: campos extras do cliente passam pela validation e podem atingir o banco ou o domínio — risco de mass assignment. Por quê: por padrão, zod permite campos extras; JSON Schema também requer
additionalProperties: falseexplícito. Como evitar: use.strict()em zod ouadditionalProperties: falseem JSON Schema para todos os schemas de input externo.
Validar só body e esquecer query/params/headers
O que acontece:
req.params.idchega como string UUID não validada; um ID com formato errado causa erro de banco, não 400. Por quê: body recebe atenção; params e query são vistos como secundários mas são tão externos quanto o body. Como evitar: trate cada parte do request comounknown; aplique schema a params, query e headers críticos.
Misturar zod e
class-validatorsem convenção claraO que acontece: metade dos endpoints valida com zod, metade com decorators; erros têm formatos diferentes; testes duplicam. Por quê: times diferentes adotam padrões diferentes sem alinhamento; NestJS vem com class-validator, mas zod é mais ergonômico em TS puro. Como evitar: escolha uma biblioteca por domínio/repositório e documente a escolha; wrappers como
nestjs-zodunificam se necessário.
Mensagens de erro cruas expostas para o cliente
O que acontece: cliente vê mensagens internas como
Expected string, received number at path "items.0.productId"diretamente na response. Por quê:ZodErroré lançado cru sem mapeamento para Problem Details cominvalidParamslegíveis. Como evitar: mapeieZodError.issuesparainvalidParamsno handler de erro global; mantenha a mensagem interna no log.
Validar depois do use case: domínio recebeu dado inválido
O que acontece: use case processa dados malformados, corrompe estado ou lança erro interno difícil de rastrear. Por quê: validation foi postergada por acidente ou colocada dentro do use case em vez de na boundary HTTP. Como evitar: validation é sempre a primeira coisa que acontece na boundary — antes de qualquer lógica de aplicação.
Coerção ampla com
z.coerceaceita input ambíguoO que acontece:
z.coerce.number().parse("abc")retornaNaN;z.coerce.boolean().parse("false")retornatrue. Por quê:z.coerceusa cast JavaScript nativo, que tem regras permissivas;Boolean("false") === true. Como evitar: usez.coerceapenas para conversões previsíveis como query string de número; adicione.refine()para casos limítrofes.
Usar schema de DTO como entity de domínio
O que acontece: lógica de domínio fica acoplada ao formato de entrada; trocar a API exige mudar o domínio. Por quê: conveniência leva a reutilizar o tipo inferido pelo schema como tipo de domínio direto. Como evitar: schema de input → DTO/input type; domínio → entity/value object com invariantes próprias; mapeie entre eles no adapter.
Alterar schema público sem versionamento
O que acontece: clientes existentes enviam payload no formato antigo e recebem 400 inesperado. Por quê: mudança incompatível (campo obrigatório, rename, remoção) foi feita sem criar nova versão da rota. Como evitar: campos novos obrigatórios exigem versão nova; use
.default()para tornar campos novos opcionais de forma compatível.
Validar webhook depois de executar efeito colateral
O que acontece: efeito colateral (email enviado, estoque decrementado) acontece com payload inválido ou de versão incompatível. Por quê: validation foi colocada após a lógica principal por simplicidade. Como evitar: em webhooks e mensagens de fila, validate primeiro com
safeParse; redirecione para dead-letter queue se inválido; nunca processe antes de validar.
Sem testes de schema: regressão silenciosa
O que acontece: alguém remove
.strict()ou adiciona.optional()e o contrato muda sem detectar. Por quê: schema é tratado como configuração, não como código que precisa de teste. Como evitar: teste schemas públicos com payloads válidos, inválidos e com campos extras; inclua no CI.
Perguntas de entrevista
Por que schema-first é melhor que validação manual? Porque centraliza contrato, reduz duplicação, permite type inference e torna erro/teste mais previsível.
Onde validar query string? Na boundary HTTP, antes do use case. Query chega como string e precisa de parsing/coerção explícita.
Qual a diferença entre DTO e entidade? DTO representa formato de entrada/saída. Entidade representa regra de domínio e invariantes internas.
Como devolver erro de validação para cliente? Com formato estruturado, idealmente Problem Details com lista de campos inválidos.
Em entrevista
“Schema-first validation is the modern Node pattern. With zod, you define a schema once and get both runtime validation and TypeScript inference through z.infer. Fastify uses JSON Schema natively for validation and serialization. NestJS traditionally uses DTO classes with class-validator through a global ValidationPipe, though zod wrappers are common. The senior pattern is validating every external boundary, not only request bodies.”
Vocabulário-chave:
- schema-first → orientado por schema
- type inference → inferência de tipo
- JSON Schema → schema JSON
- ValidationPipe → pipe de validação
- external boundary → fronteira externa
O que vem a seguir
Com validation na boundary garantida, o próximo passo é organizar toda a aplicação ao redor de camadas que respeitam esses contratos. 10 - Clean Architecture em Node mostra como estruturar domínio, use cases e adapters para que schema de input e error handling fiquem nas bordas corretas. Depois, 11 - DI - manual vs container fecha o ciclo: como montar o grafo de dependências que conecta boundary, use case e infraestrutura.