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 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.
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.
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);
});Na prática
zod é forte em projetos TypeScript por type inference e ergonomia. Fastify brilha quando JSON Schema já é o contrato da API e OpenAPI importa. NestJS clássico usa DTO class + decorators; projetos novos também podem padronizar zod com wrapper. joi ainda aparece em legacy, mas não é a escolha natural para TS-first.
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
- Schema permissivo sem
.strict()ouadditionalProperties: false. - Validar só body e esquecer query/params/headers.
- Misturar zod e
class-validatorsem convenção clara. - Mensagens de erro cruas demais para cliente final.
- Validar depois do use case: domínio já recebeu dado inválido.
- Transformar tudo em string com coerção e aceitar input ambíguo.
- Usar schema de DTO como entity de domínio.
- Alterar schema público sem versionamento.
- Validar webhook depois de executar efeito colateral.
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