Ir para o conteúdo
Framework com Framework1827 palavras

drizzle-zod: validação type-safe do Drizzle ORM com Zod

Introdução

Se você trabalha com TypeScript em projetos backend, provavelmente já enfrentou o seguinte dilema: seus tipos existem no banco de dados, existem no ORM e existem nos schemas de validação. Manter tudo isso sincronizado manualmente é uma receita para bugs silenciosos e horas perdidas em debugging.

Versões conferidas nesta revisão (setembro de 2026)

PacoteVersãoObservação
drizzle-orm0.45.2drizzle-zod exige >= 0.36.0
drizzle-zod0.8.3declara compatibilidade com zod ^3.25.0 || ^4.0.0
zod4.5.4com Zod 4, importe de zod (não de zod/v3) e use drizzle-zod 0.8 ou superior; versões anteriores do drizzle-zod só aceitam Zod 3

Fixe as três no package.json antes de copiar os exemplos: createInsertSchema e createSelectSchema mudaram de assinatura entre o 0.5 e o 0.8 do drizzle-zod, e a maioria dos erros de tipo reportados nos comentários vem de combinações fora dessa tabela. Os exemplos abaixo foram escritos para essas versões; rode npx tsc --noEmit depois de instalar para confirmar que a inferência de tipos fecha no seu projeto.

A combinação de Drizzle ORM com Zod resolve esse problema de forma elegante. O Drizzle cuida do acesso ao banco de dados com SQL type-safe, enquanto o Zod valida os dados de entrada e saída da sua aplicação. Quando integrados, eles compartilham a mesma fonte de verdade: o schema do seu banco de dados.

Neste guia, vou mostrar como configurar essa integração do zero, com exemplos práticos que você pode aplicar diretamente em projetos reais. Se você já tem experiência com TypeScript e quer elevar o nível de segurança de tipos na sua aplicação, este artigo é para você.

Caso esteja em dúvida sobre qual ORM escolher para seu projeto, recomendo conferir nosso post Prisma ORM vs Drizzle ORM: Benchmark Real em 2025 antes de continuar.

Por que usar Drizzle com Zod juntos

O problema da validação duplicada

Em uma aplicação típica, você define o schema do banco, cria tipos TypeScript correspondentes e depois escreve schemas de validação separados para cada rota da API. O resultado são três representações da mesma entidade que podem facilmente sair de sincronia.

Imagine que você adiciona uma coluna phone na tabela de usuários. Você atualiza a migration, atualiza o schema do Drizzle, mas esquece de atualizar o schema Zod da rota de criação de usuário. O TypeScript não vai reclamar porque são estruturas independentes. O bug só aparece em produção quando alguém tenta cadastrar um telefone e o campo simplesmente desaparece.

A solução: uma única fonte de verdade

O pacote drizzle-zod gera automaticamente schemas Zod a partir das suas tabelas Drizzle. Isso significa que quando você altera o schema do banco, os schemas de validação se atualizam automaticamente. Sem duplicação, sem dessincronia.

As vantagens práticas incluem:

  • Validação de entrada (insert) e saída (select) gerada automaticamente
  • Refinamentos customizados sobre os schemas gerados
  • Compatibilidade total com o ecossistema Zod (incluindo tRPC, Hono, Fastify e outros)
  • Inferência de tipos TypeScript sem necessidade de declaração manual

Casos de uso reais onde essa combinação brilha

Essa integração se destaca especialmente em APIs REST e tRPC onde a validação de payload é crítica, sistemas multi-tenant que precisam de validação rigorosa antes de qualquer operação no banco, aplicações com formulários complexos onde o schema do frontend precisa espelhar o backend, e projetos que usam monorepos e compartilham tipos entre cliente e servidor.

Setup e instalação do Drizzle com Zod

Instalando as dependências

Vamos começar com um projeto Node.js com TypeScript já configurado. Instale as dependências necessárias:

TYPESCRIPT
// Execute no terminal:
// npm install drizzle-orm zod drizzle-zod
// npm install -D drizzle-kit @types/node

O drizzle-zod é o pacote que faz a ponte entre os dois mundos. Ele é mantido pela própria equipe do Drizzle e tem atualizações frequentes.

Configurando o Drizzle

Crie o arquivo de configuração do Drizzle e defina seu primeiro schema. Neste exemplo, vou usar PostgreSQL, mas a integração funciona igualmente com MySQL e SQLite:

TYPESCRIPT
// src/db/schema.ts
import { pgTable, serial, varchar, integer, boolean, timestamp, text } from "drizzle-orm/pg-core";
 
export const users = pgTable("users", {
  id: serial("id").primaryKey(),
  name: varchar("name", { length: 255 }).notNull(),
  email: varchar("email", { length: 255 }).notNull().unique(),
  age: integer("age"),
  isActive: boolean("is_active").notNull().default(true),
  bio: text("bio"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
 
export const posts = pgTable("posts", {
  id: serial("id").primaryKey(),
  title: varchar("title", { length: 500 }).notNull(),
  content: text("content").notNull(),
  published: boolean("published").notNull().default(false),
  authorId: integer("author_id")
    .notNull()
    .references(() => users.id),
  createdAt: timestamp("created_at").notNull().defaultNow(),
});

Gerando schemas Zod a partir do Drizzle

Aqui é onde a mágica acontece. O drizzle-zod expõe duas funções principais: createInsertSchema e createSelectSchema.

TYPESCRIPT
// src/db/validation.ts
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { z } from "zod";
import { users, posts } from "./schema";
 
// Schema para inserção de usuários
// Campos com default ou autoincrement se tornam opcionais automaticamente
export const insertUserSchema = createInsertSchema(users);
 
// Schema para leitura de usuários
// Todos os campos são obrigatórios (reflete o que vem do banco)
export const selectUserSchema = createSelectSchema(users);
 
// Inferindo tipos TypeScript a partir dos schemas
export type InsertUser = z.infer<typeof insertUserSchema>;
export type SelectUser = z.infer<typeof selectUserSchema>;
 
// Schema para inserção de posts
export const insertPostSchema = createInsertSchema(posts);
export const selectPostSchema = createSelectSchema(posts);
 
export type InsertPost = z.infer<typeof insertPostSchema>;
export type SelectPost = z.infer<typeof selectPostSchema>;

Observe que o createInsertSchema já entende que campos como id (autoincrement), createdAt (defaultNow) e isActive (default true) são opcionais na inserção. Você não precisa configurar nada disso manualmente.

Exemplo prático: API de usuários com validação completa

Vamos construir um exemplo completo com rotas de criação e atualização de usuários, demonstrando refinamentos customizados e tratamento de erros.

TYPESCRIPT
// src/modules/users/users.schemas.ts
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { z } from "zod";
import { users } from "../../db/schema";
 
// Schema base gerado automaticamente
const baseInsertSchema = createInsertSchema(users);
 
// Schema refinado com validações de negócio
export const createUserSchema = baseInsertSchema
  .extend({
    email: z.string().email("E-mail inválido").toLowerCase(),
    name: z.string().min(2, "Nome deve ter ao menos 2 caracteres").max(255),
    age: z.number().int().min(18, "Idade mínima é 18 anos").max(120).optional(),
    bio: z.string().max(1000, "Bio deve ter no máximo 1000 caracteres").optional(),
  })
  .omit({
    id: true,
    createdAt: true,
    updatedAt: true,
  });
 
// Schema para atualização parcial (todos os campos opcionais)
export const updateUserSchema = createUserSchema.partial();
 
// Schema de resposta da API (sem campos sensíveis, se houvesse)
export const userResponseSchema = createSelectSchema(users);
 
// Schema para query params de listagem
export const listUsersQuerySchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  search: z.string().optional(),
  isActive: z.coerce.boolean().optional(),
});
 
// Tipos inferidos
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export type UserResponse = z.infer<typeof userResponseSchema>;
export type ListUsersQuery = z.infer<typeof listUsersQuerySchema>;

Agora, vamos usar esses schemas em um handler real. O exemplo abaixo funciona com qualquer framework HTTP, mas estou usando uma estrutura genérica para manter o foco na integração:

TYPESCRIPT
// src/modules/users/users.service.ts
import { eq, ilike, and, SQL } from "drizzle-orm";
import { db } from "../../db/client";
import { users } from "../../db/schema";
import {
  createUserSchema,
  updateUserSchema,
  listUsersQuerySchema,
  type CreateUserInput,
  type UpdateUserInput,
  type ListUsersQuery,
} from "./users.schemas";
 
export class UsersService {
  async create(rawInput: unknown) {
    // Validação com Zod: se falhar, lança ZodError
    const input: CreateUserInput = createUserSchema.parse(rawInput);
 
    // Neste ponto, input é totalmente tipado e validado
    // O TypeScript sabe exatamente quais campos existem
    const [newUser] = await db
      .insert(users)
      .values({
        name: input.name,
        email: input.email,
        age: input.age,
        bio: input.bio,
      })
      .returning();
 
    return newUser;
  }
 
  async update(id: number, rawInput: unknown) {
    const input: UpdateUserInput = updateUserSchema.parse(rawInput);
 
    // Se o objeto estiver vazio após validação, não faz sentido atualizar
    if (Object.keys(input).length === 0) {
      throw new Error("Nenhum campo para atualizar");
    }
 
    const [updatedUser] = await db
      .update(users)
      .set({ ...input, updatedAt: new Date() })
      .where(eq(users.id, id))
      .returning();
 
    if (!updatedUser) {
      throw new Error("Usuário não encontrado");
    }
 
    return updatedUser;
  }
 
  async list(rawQuery: unknown) {
    const query: ListUsersQuery = listUsersQuerySchema.parse(rawQuery);
 
    const conditions: SQL[] = [];
 
    if (query.search) {
      conditions.push(ilike(users.name, `%${query.search}%`));
    }
 
    if (query.isActive !== undefined) {
      conditions.push(eq(users.isActive, query.isActive));
    }
 
    const offset = (query.page - 1) * query.limit;
 
    const results = await db
      .select()
      .from(users)
      .where(conditions.length > 0 ? and(...conditions) : undefined)
      .limit(query.limit)
      .offset(offset);
 
    return results;
  }
}

Exemplo prático: integração com tRPC

Se você usa tRPC, a combinação com Drizzle e Zod fica ainda mais poderosa, já que o tRPC usa Zod nativamente para definir os contratos das procedures:

TYPESCRIPT
// src/trpc/routers/posts.router.ts
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
import { posts, users } from "../../db/schema";
import { eq } from "drizzle-orm";
import { db } from "../../db/client";
import { router, publicProcedure, protectedProcedure } from "../trpc";
 
const createPostInput = createInsertSchema(posts)
  .extend({
    title: z.string().min(5, "Título deve ter ao menos 5 caracteres"),
    content: z.string().min(50, "Conteúdo deve ter ao menos 50 caracteres"),
  })
  .omit({
    id: true,
    authorId: true,
    createdAt: true,
  });
 
const updatePostInput = createPostInput.partial().extend({
  id: z.number().int().positive(),
});
 
export const postsRouter = router({
  create: protectedProcedure
    .input(createPostInput)
    .mutation(async ({ input, ctx }) => {
      const [post] = await db
        .insert(posts)
        .values({
          title: input.title,
          content: input.content,
          published: input.published,
          authorId: ctx.user.id,
        })
        .returning();
 
      return post;
    }),
 
  getById: publicProcedure
    .input(z.object({ id: z.number().int().positive() }))
    .query(async ({ input }) => {
      const [post] = await db
        .select()
        .from(posts)
        .where(eq(posts.id, input.id));
 
      if (!post) {
        throw new Error("Post não encontrado");
      }
 
      return post;
    }),
 
  update: protectedProcedure
    .input(updatePostInput)
    .mutation(async ({ input, ctx }) => {
      const { id, ...data } = input;
 
      const [post] = await db
        .update(posts)
        .set(data)
        .where(and(eq(posts.id, id), eq(posts.authorId, ctx.user.id)))
        .returning();
 
      return post;
    }),
});

Nesse exemplo, o contrato da API é derivado diretamente do schema do banco. Se você adicionar uma coluna na tabela posts, o TypeScript vai reclamar em todos os lugares onde a mudança for relevante.

Boas práticas para Drizzle com Zod em produção

Separe schemas de transporte dos schemas de persistência

Mesmo com a geração automática, crie schemas específicos para cada camada da aplicação. O schema de inserção no banco não deve ser o mesmo schema que sua API expõe. Use .omit(), .pick() e .extend() para criar variações:

TYPESCRIPT
// Schema de persistência (gerado)
const baseSchema = createInsertSchema(users);
 
// Schema da API (customizado)
const apiCreateSchema = baseSchema
  .omit({ id: true, createdAt: true, updatedAt: true })
  .extend({ passwordConfirmation: z.string() })
  .refine(
    (data) => data.password === data.passwordConfirmation,
    { message: "Senhas não conferem", path: ["passwordConfirmation"] }
  );

Use safeParse para tratamento de erros controlado

Em vez de parse (que lança exceção), prefira safeParse quando precisar de controle fino sobre os erros:

TYPESCRIPT
const result = createUserSchema.safeParse(requestBody);
 
if (!result.success) {
  return {
    status: 400,
    body: {
      message: "Dados inválidos",
      errors: result.error.flatten().fieldErrors,
    },
  };
}
 
// result.data é totalmente tipado aqui
const user = await usersService.create(result.data);

Mantenha os schemas próximos do domínio

Organize seus schemas no mesmo módulo da entidade correspondente. Evite criar um arquivo gigante com todos os schemas da aplicação. A estrutura recomendada é agrupar cada módulo com seu schema de banco, seus schemas de validação e suas rotas ou procedures.

Não ignore os tipos gerados pelo select schema

O createSelectSchema é extremamente útil para validar dados que saem do banco antes de env

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