Toda vez que sua API recebe uma requisição, você a valida. Toda vez que uma função recebe um argumento de um sistema externo, você o verifica. Faça isso manualmente, e um único endpoint pode acumular mais código de validação do que lógica de negócio.
Esse é o imposto oculto dos runtime contracts. Você precisa deles porque o sistema de tipos mente: JSON sobre HTTP, campos de banco de dados e variáveis de ambiente chegam todos como dados não tipados em runtime. Mas implementá-los com instruções if escritas à mão transforma funções simples em muros de código defensivo ilegíveis.
Existe um jeito melhor.
O que são, na verdade, os runtime contracts
Um runtime contract é uma garantia que uma função faz sobre suas entradas e saídas. Se você passar dados válidos, recebe dados válidos de volta. Se violar o contract, a função falha rápido com um erro claro.
Isso é Design by Contract, e existe desde o Eiffel. A ideia não é nova. O que é novo é o tooling que permite expressar contracts sem escrever um validador para cada campo.
A ideia central: contracts são schemas, não código. Um schema descreve formato, restrições e relacionamentos. Código que verifica isso manualmente é apenas um schema escrito na linguagem errada.
A armadilha do boilerplate
É assim que a validação manual se parece na prática. Você tem uma função que processa uma requisição de cadastro de usuário:
function createUser(payload: unknown) {
if (!payload || typeof payload !== "object") {
throw new Error("Invalid payload");
}
if (!("email" in payload) || typeof (payload as any).email !== "string") {
throw new Error("email is required and must be a string");
}
if (!(payload as any).email.includes("@")) {
throw new Error("email must be valid");
}
if (!("age" in payload) || typeof (payload as any).age !== "number") {
throw new Error("age is required and must be a number");
}
if ((payload as any).age < 13 || !Number.isInteger((payload as any).age)) {
throw new Error("age must be an integer of at least 13");
}
if (
"tags" in payload &&
(!Array.isArray((payload as any).tags) ||
!(payload as any).tags.every((t: any) => typeof t === "string"))
) {
throw new Error("tags must be an array of strings");
}
// ... finally, the actual work
}
Isso é exaustivo, propenso a erros e incompleto. Você verificou se o email corresponde a um formato de email real? Se a age não é NaN? Se os elementos de tags são strings não vazias? Provavelmente não, porque você se cansou.
E isso é apenas uma função. Multiplique por cada boundary do seu sistema.
Como schemas declarativos substituem o boilerplate
Em vez de escrever validadores, descreva a forma e deixe uma biblioteca garanti-la.
import { z } from "zod";
const UserSchema = z.object({
email: z.string().email(),
age: z.number().int().min(13),
tags: z.array(z.string().min(1)).optional(),
});
function createUser(payload: unknown) {
const user = UserSchema.parse(payload);
// user is typed as { email: string; age: number; tags?: string[] }
// actual business logic goes here
}
O schema é a documentação. Ele também é o validador, a definição de tipo e o gerador de mensagens de erro. Altere o schema em um lugar, e o contract é atualizado em todos os lugares onde é usado.
Isso não é mágica. A biblioteca percorre o schema em runtime e aplica as verificações. A diferença é que as verificações são compostas a partir de pequenos primitivos reutilizáveis, em vez de serem escritos à mão toda vez.
Contracts nos limites das funções
O verdadeiro poder vem de anexar schemas diretamente às entradas e saídas da função. Você quer que o contract viva no limite, não vaze para a implementação.
function withContract<TInput, TOutput>(
inputSchema: z.ZodSchema<TInput>,
outputSchema: z.ZodSchema<TOutput>,
handler: (input: TInput) => TOutput
) {
return (rawInput: unknown): TOutput => {
const input = inputSchema.parse(rawInput);
const output = handler(input);
return outputSchema.parse(output);
};
}
const UserResponseSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
});
const createUser = withContract(
UserSchema,
UserResponseSchema,
(user) => {
// zero validation code here
return db.users.create(user);
}
);
Agora o corpo da função contém zero código de validação. O contract é garantido na borda, e o compiler ainda conhece os tipos internamente porque o schema os infere.
Onde isso falha: desempenho, inferência e validação excessiva
Schemas declarativos não são de graça. Você deve conhecer os trade-offs antes de adotá-los.
O desempenho é a preocupação óbvia. Fazer o parsing de um schema complexo é mais lento do que algumas verificações if. Na prática, a diferença é negligenciável para a maioria das APIs. O Zod é aproximadamente 10× mais lento do que verificações feitas à mão para objetos simples. Para a maioria dos handlers HTTP, isso são microssegundos de diferença. Se você está validando milhões de registros em um hot loop, faça benchmark primeiro.
Lacunas de inferência de tipo também podem te pegar. Tipos inferidos a partir de schemas às vezes são mais estritos ou mais frouxos do que o que você escreveria à mão. Você pode precisar definir tipos customizados e dizer ao schema para corresponder a eles, em vez do contrário.
A validação excessiva é o assassino silencioso. Nem toda função precisa de um runtime contract. Funções internas que recebem dados já validados no boundary da API não precisam ser reverificadas. Aplique contracts nos boundaries do sistema, não em cada função auxiliar.
Como implementar isso sem reescrever sua codebase
Você não precisa de uma migration big-bang. Comece pelos pontos de entrada da sua API.
- Escolha uma função voltada para o exterior ou um route handler.
- Escreva um schema para sua entrada.
- Substitua a validação manual por parsing de schema.
- Deixe o schema inferir o tipo TypeScript. Exclua sua interface escrita à mão.
Faça isso para um endpoint, depois para um serviço, depois para um domínio. Em uma semana, você vai notar suas suites de teste diminuindo porque não precisa mais testar casos de borda de validação que o schema já cobre.
Se você não está usando TypeScript, o padrão se traduz. Python tem Pydantic. Go tem go-playground/validator. Rust tem validator. O princípio é o mesmo: descreva o contract, gere as verificações.
FAQ
Os runtime contracts substituem unit tests?
Não. Eles substituem o subconjunto de unit tests que verificam a validação de entrada. Você ainda testa lógica de negócio, tratamento de erros e comportamento de integração.
E quanto a regras de validação customizadas?
Toda biblioteca de schema séria suporta refinamentos customizados. Use-os com moderação. Se sua regra customizada tiver mais de cinco linhas, ela provavelmente pertence ao corpo da função, não ao schema.
Devo validar também na saída?
Sim, para funções críticas. Um output contract captura bugs onde sua lógica de negócio produz dados malformados. É mais barato falhar na fonte do que deixar dados ruins se propagarem para outro serviço.
Conclusão
Runtime contracts são inegociáveis para sistemas que tocam dados não tipados. A única escolha é se você paga por eles com boilerplate ou com uma biblioteca de schemas.
A biblioteca de schemas custa uma dependência e uma pequena sobrecarga de desempenho. O boilerplate custa manutenibilidade, corretude e sua sanidade.
Escolha a dependência.