Como usar NestJS com Zod: Guia Completo
Introdução
Se você trabalha com NestJS há algum tempo, já conhece o class-validator e os decorators que vêm por padrão no framework. Eles funcionam, resolvem o problema básico, mas carregam limitações que incomodam conforme o projeto cresce. Tipos inferidos manualmente, validações complexas que viram uma sopa de decorators e a desconexão entre o schema de validação e o tipo TypeScript são problemas reais que aparecem no dia a dia.
Versões conferidas nesta revisão (setembro de 2026)
| Pacote | Versão | Observação |
|---|---|---|
@nestjs/core e @nestjs/common | 12.0.1 | versão atual no npm |
nestjs-zod | 5.5.0 | declara suporte a @nestjs/common ^10.0.0 || ^11.0.0, @nestjs/swagger ^7.4.2 || ^8 || ^11 e zod ^3.25.0 || ^4.0.0 |
@nestjs/swagger | 12.0.1 | versão atual no npm |
zod | 4.5.4 | suportado pelo nestjs-zod 5.x |
Detalhe que trava instalação: o nestjs-zod 5.5.0 ainda não declara o NestJS 12 como peer dependency. Em projeto novo com NestJS 12, o npm install nestjs-zod para em conflito de peer; as saídas são fixar o NestJS em 11.x até o pacote atualizar, ou instalar com --legacy-peer-deps e rodar a suíte de testes do projeto para confirmar que o pipe de validação funciona. Os exemplos abaixo não dependem de API que mudou entre o 11 e o 12.
O Zod resolve essas dores de forma elegante. Ele é uma biblioteca de validação e parsing com tipagem inferida automaticamente, o que significa que você define o schema uma única vez e extrai o tipo TypeScript diretamente dele. Sem duplicação, sem dessincronia entre validação e tipagem.
Neste guia, vou mostrar como integrar NestJS com Zod de forma prática, cobrindo desde a configuração inicial até padrões que uso em projetos de produção. O objetivo é que você saia daqui com tudo que precisa para aplicar essa combinação no seu próximo projeto.
Por que usar NestJS com Zod
Limitações do class-validator em projetos reais
O class-validator depende de decorators experimentais e de classes para funcionar. Isso gera alguns problemas concretos:
- Você precisa definir uma classe DTO e decorar cada propriedade individualmente.
- O tipo TypeScript da classe nem sempre reflete as validações aplicadas. Você pode ter um
@IsOptional()no decorator, mas o tipo da propriedade ser obrigatório (ou vice-versa). - Validações condicionais e composições complexas ficam verbosas e difíceis de manter.
- Não existe inferência automática de tipos a partir das validações.
Vantagens do Zod como engine de validação
O Zod trabalha com uma abordagem "schema-first": você declara o schema e o tipo nasce dele. As principais vantagens são:
- Inferência automática de tipos com
z.infer<typeof schema>. - Composição poderosa com
.merge(),.extend(),.pick(),.omit()e.refine(). - Validação em runtime que garante segurança real, não apenas em tempo de compilação.
- Mensagens de erro customizáveis e estruturadas.
- Zero dependência de decorators experimentais.
Casos de uso onde essa combinação brilha
Na prática, NestJS com Zod funciona especialmente bem em cenários como:
- APIs que precisam validar payloads complexos com campos condicionais (por exemplo, um campo
documentque muda de formato dependendo dodocumentType). - Projetos que compartilham schemas entre frontend e backend em um monorepo.
- Microsserviços que consomem mensagens de filas e precisam validar o formato antes de processar.
- Validação de variáveis de ambiente no bootstrap da aplicação.
Setup do NestJS com Zod
Instalação das dependências
Vamos começar com um projeto NestJS existente. Instale o Zod e o pacote de integração com o pipe de validação do Nest:
// Terminal
// npm install zod nestjs-zod
// npm install -D @types/nodeO pacote nestjs-zod fornece um ZodValidationPipe pronto para uso, além de helpers para criar DTOs a partir de schemas Zod. Se preferir não usar essa lib auxiliar, mostrarei também como criar seu próprio pipe.
Criando um ZodValidationPipe customizado
Para quem prefere não adicionar dependências extras, criar um pipe de validação customizado é simples e dá controle total sobre o comportamento:
// src/common/pipes/zod-validation.pipe.ts
import {
PipeTransform,
ArgumentMetadata,
BadRequestException,
Injectable,
} from '@nestjs/common';
import { ZodSchema, ZodError } from 'zod';
@Injectable()
export class ZodValidationPipe implements PipeTransform {
constructor(private schema: ZodSchema) {}
transform(value: unknown, metadata: ArgumentMetadata) {
const result = this.schema.safeParse(value);
if (!result.success) {
const formattedErrors = this.formatErrors(result.error);
throw new BadRequestException({
message: 'Erro de validação',
errors: formattedErrors,
});
}
return result.data;
}
private formatErrors(error: ZodError): Record<string, string[]> {
const formatted: Record<string, string[]> = {};
error.issues.forEach((issue) => {
const path = issue.path.join('.');
const key = path || '_root';
if (!formatted[key]) {
formatted[key] = [];
}
formatted[key].push(issue.message);
});
return formatted;
}
}Repare que o pipe usa safeParse em vez de parse. Isso evita que o Zod lance uma exceção não tratada e nos permite formatar o erro antes de devolver ao cliente.
Exemplo Prático: CRUD de Usuários com Validação Zod
Definindo os schemas de validação
Vamos construir um módulo de usuários completo. Primeiro, os schemas:
// src/modules/users/schemas/user.schema.ts
import { z } from 'zod';
export const CreateUserSchema = z.object({
name: z
.string()
.min(2, 'O nome deve ter pelo menos 2 caracteres')
.max(100, 'O nome deve ter no máximo 100 caracteres')
.trim(),
email: z
.string()
.email('Formato de e-mail inválido')
.toLowerCase(),
password: z
.string()
.min(8, 'A senha deve ter pelo menos 8 caracteres')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'A senha deve conter letras maiúsculas, minúsculas e números'
),
role: z.enum(['admin', 'user', 'editor'], {
errorMap: () => ({ message: 'Role deve ser admin, user ou editor' }),
}),
profile: z.object({
bio: z.string().max(500).optional(),
avatarUrl: z.string().url('URL do avatar inválida').optional(),
birthDate: z
.string()
.datetime({ message: 'Data de nascimento deve estar em formato ISO 8601' })
.optional(),
}).optional(),
});
export const UpdateUserSchema = CreateUserSchema.partial().omit({
password: true,
});
export const QueryUsersSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
role: z.enum(['admin', 'user', 'editor']).optional(),
search: z.string().min(1).optional(),
});
// Tipos inferidos automaticamente
export type CreateUserDto = z.infer<typeof CreateUserSchema>;
export type UpdateUserDto = z.infer<typeof UpdateUserSchema>;
export type QueryUsersDto = z.infer<typeof QueryUsersSchema>;Observe como o UpdateUserSchema reutiliza o CreateUserSchema com .partial() e .omit(). Isso elimina a duplicação que seria inevitável com class-validator. Quando você alterar uma regra no schema de criação, o schema de atualização reflete a mudança automaticamente.
Outro detalhe importante: o QueryUsersSchema usa z.coerce.number() para query parameters. Como tudo que vem da URL chega como string, o coerce faz a conversão automática.
Implementando o controller com validação
Agora o controller que consome esses schemas:
// src/modules/users/users.controller.ts
import {
Controller,
Get,
Post,
Put,
Param,
Body,
Query,
UsePipes,
ParseUUIDPipe,
} from '@nestjs/common';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import {
CreateUserSchema,
UpdateUserSchema,
QueryUsersSchema,
CreateUserDto,
UpdateUserDto,
QueryUsersDto,
} from './schemas/user.schema';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
@UsePipes(new ZodValidationPipe(CreateUserSchema))
async create(@Body() dto: CreateUserDto) {
// dto já está validado, tipado e transformado (trim, toLowerCase)
return this.usersService.create(dto);
}
@Put(':id')
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(UpdateUserSchema)) dto: UpdateUserDto,
) {
return this.usersService.update(id, dto);
}
@Get()
async findAll(
@Query(new ZodValidationPipe(QueryUsersSchema)) query: QueryUsersDto,
) {
// query.page e query.limit já são numbers, não strings
return this.usersService.findAll(query);
}
}A diferença entre @UsePipes() no método e o pipe direto no decorator do parâmetro é sutil mas relevante. Usar @UsePipes() aplica a validação a todos os parâmetros do método. Passar o pipe diretamente no @Body() ou @Query() dá controle granular sobre qual parâmetro será validado com qual schema.
Validação de Variáveis de Ambiente com Zod no NestJS
Um caso de uso poderoso que muitos ignoram é validar as variáveis de ambiente no momento do bootstrap:
// src/config/env.schema.ts
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
PORT: z.coerce.number().int().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, 'JWT_SECRET deve ter pelo menos 32 caracteres'),
REDIS_HOST: z.string().default('localhost'),
REDIS_PORT: z.coerce.number().int().default(6379),
CORS_ORIGINS: z
.string()
.transform((val) => val.split(',').map((s) => s.trim()))
.default('http://localhost:3000'),
});
export type EnvConfig = z.infer<typeof EnvSchema>;
export function validateEnv(): EnvConfig {
const result = EnvSchema.safeParse(process.env);
if (!result.success) {
console.error('Configuração de ambiente inválida:');
console.error(result.error.flatten().fieldErrors);
process.exit(1);
}
return result.data;
}Chamar validateEnv() no main.ts antes de iniciar o servidor garante que a aplicação nunca suba com configuração inválida ou incompleta. Em produção, isso evita bugs silenciosos que só aparecem quando uma feature específica tenta acessar uma variável que não existe.
Boas Práticas para NestJS com Zod
Organize schemas em arquivos separados
Mantenha os schemas próximos ao módulo que os consome, mas em arquivos dedicados. A estrutura que recomendo é:
src/modules/users/
schemas/
user.schema.ts
user-query.schema.ts
users.controller.ts
users.service.ts
users.module.tsCrie schemas base e componha a partir deles
Em vez de repetir validações comuns como email, senha ou paginação, extraia schemas reutilizáveis:
// src/common/schemas/pagination.schema.ts
import { z } from 'zod';
export const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.string().optional(),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
export type PaginationDto = z.infer<typeof PaginationSchema>;Depois, nos módulos específicos:
const QueryUsersSchema = PaginationSchema.extend({
role: z.enum(['admin', 'user', 'editor']).optional(),
search: z.string().optional(),
});Use o pipe global com cuidado
Você pode registrar o ZodValidationPipe como pipe global, mas isso exige que todo endpoint tenha um schema associado. Na maioria dos projetos, aplicar o pipe por rota ou por parâmetro oferece mais flexibilidade.
Prefira safeParse a parse
Sempre use safeParse no pipe de validação. O parse lança exceções do tipo ZodError, que não são automaticamente tratadas pelo exception filter do NestJS. Com safeParse, você tem controle total sobre o formato do erro retornado ao cliente.
Teste os schemas isoladamente
Uma vantagem frequentemente subestimada: schemas Zod são funções puras, sem dependência do framework. Isso significa que você pode testá-los unitariamente sem levantar o contexto do NestJS:
describe('CreateUserSchema', () => {
it('deve rejeitar email inválido', () => {
const result = CreateUserSchema.safeParse({
name: 'João',
email: 'nao-eh-email',
password: 'Senha123',
role: 'user',
});
expect(result.success).toBe(false);
});
});Conclusão
A combinação de NestJS com Zod elimina a classe de bugs que surge da dessincronia entre validação e tipagem. Você define o schema uma vez, infere o tipo automaticamente e tem garantia de que os dados passaram pela validação antes de chegar ao service.
Na minha experiência, essa abordagem reduz código duplicado, simplifica refatorações e torna a validação de dados algo previsível em vez de uma fonte constante de surpresas. Se você está começando um projeto novo com NestJS ou considerando migrar a camada de validação de um projeto existente, Zod é uma escolha sólida que vale o investimento de aprendizado.
O próximo passo natural é explorar o compartilhamento de schemas entre frontend e backend em monorepos, onde essa abordagem realmente mostra todo o seu potencial. Mas isso fica para um próximo artigo.
Guias Relacionados
Explore mais guias técnicos
Mais de 100 guias práticos de integração entre frameworks, ferramentas e plataformas.
Ver todos os guias