Ir para o conteúdo
Framework com Framework1709 palavras

Como usar Fastify com Zod: Guia Completo

Introdução

Validação de dados é uma daquelas responsabilidades que separam uma API amadora de uma API profissional. Se você já trabalhou com Express e passou horas escrevendo validações manuais ou lidando com bibliotecas pesadas como Joi ou Yup, sabe exatamente do que estou falando. A boa notícia é que existe uma combinação poderosa que resolve esse problema de forma elegante: Fastify com Zod.

Fastify é um framework web para Node.js focado em performance e developer experience. Zod é uma biblioteca de validação e parsing com inferência de tipos TypeScript nativa. Quando você junta os dois, o resultado é uma API onde os schemas de validação, os tipos TypeScript e a documentação Swagger vivem em um único lugar, sem duplicação e sem dessincronia.

Neste guia, vou mostrar passo a passo como integrar Fastify com Zod em projetos reais, desde a configuração inicial até padrões avançados que uso em produção há mais de três anos.

Por Que Usar Fastify com Zod Juntos

Performance do Fastify versus outros frameworks

O Fastify é significativamente mais rápido que o Express. Internamente, ele usa o esquema JSON Schema para serialização de respostas, o que reduz o overhead de cada request. Em benchmarks consistentes, o Fastify processa entre duas e três vezes mais requisições por segundo que o Express em cenários equivalentes.

Mas a performance não é o único motivo. O sistema de plugins do Fastify é maduro, com suporte nativo a TypeScript, validação de schemas, geração automática de Swagger e um ciclo de vida de requests bem definido.

Zod como fonte única de verdade para tipos

O grande problema de usar JSON Schema diretamente no Fastify é que você acaba mantendo dois artefatos separados: o schema de validação e a interface TypeScript. Com Zod, você escreve o schema uma vez e infere o tipo automaticamente com z.infer<typeof schema>. Isso elimina uma classe inteira de bugs onde o tipo diz uma coisa e a validação aceita outra.

Casos de uso reais onde essa combinação brilha

Essa stack se destaca especialmente em: APIs REST que precisam de documentação Swagger automática, microsserviços com contratos rígidos entre equipes, aplicações que compartilham schemas entre frontend e backend (monorepos), e sistemas que exigem validação rigorosa de payloads complexos (fintech, healthtech, e-commerce).

Setup Inicial do Projeto

Instalando as dependências necessárias

Vamos começar do zero. Crie um novo projeto e instale as dependências:

TYPESCRIPT
// Execute no terminal:
// mkdir fastify-zod-api && cd fastify-zod-api
// npm init -y
// npm install fastify @fastify/swagger @fastify/swagger-ui zod
// npm install fastify-type-provider-zod
// npm install -D typescript @types/node tsx

O pacote fastify-type-provider-zod é a peça central dessa integração. Ele conecta o Zod ao sistema de type providers do Fastify, permitindo que schemas Zod sejam usados diretamente nas definições de rota com inferência completa de tipos.

Configurando o TypeScript

Crie um tsconfig.json com a configuração recomendada:

TYPESCRIPT
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"]
}

Exemplo Prático: API de Gerenciamento de Produtos

Agora vamos construir algo real. Vou criar uma API de produtos com validação completa de entrada e saída, tratamento de erros e documentação automática.

Definindo os schemas com Zod

TYPESCRIPT
// src/schemas/product.schemas.ts
import { z } from "zod";
 
export const ProductSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(3).max(200),
  description: z.string().max(2000).optional(),
  price: z.number().positive().multipleOf(0.01),
  category: z.enum(["electronics", "clothing", "food", "books"]),
  inStock: z.boolean().default(true),
  tags: z.array(z.string().min(1)).max(10).default([]),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});
 
export const CreateProductSchema = ProductSchema.omit({
  id: true,
  createdAt: true,
  updatedAt: true,
});
 
export const UpdateProductSchema = CreateProductSchema.partial();
 
export const ProductParamsSchema = z.object({
  id: z.string().uuid({ message: "O ID do produto deve ser um UUID válido" }),
});
 
export const ProductQuerySchema = z.object({
  category: ProductSchema.shape.category.optional(),
  minPrice: z.coerce.number().positive().optional(),
  maxPrice: z.coerce.number().positive().optional(),
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});
 
// Tipos inferidos automaticamente dos schemas
export type Product = z.infer<typeof ProductSchema>;
export type CreateProduct = z.infer<typeof CreateProductSchema>;
export type UpdateProduct = z.infer<typeof UpdateProductSchema>;
export type ProductParams = z.infer<typeof ProductParamsSchema>;
export type ProductQuery = z.infer<typeof ProductQuerySchema>;

Observe como z.coerce.number() é usado nos query params. Isso é fundamental porque query strings sempre chegam como string, e o Zod faz a conversão automática para número antes de validar.

Configurando o servidor Fastify com Zod Type Provider

TYPESCRIPT
// src/server.ts
import Fastify from "fastify";
import fastifySwagger from "@fastify/swagger";
import fastifySwaggerUi from "@fastify/swagger-ui";
import {
  serializerCompiler,
  validatorCompiler,
  jsonSchemaTransform,
  ZodTypeProvider,
} from "fastify-type-provider-zod";
import { productRoutes } from "./routes/product.routes.js";
 
async function buildServer() {
  const app = Fastify({
    logger: {
      level: "info",
      transport: {
        target: "pino-pretty",
      },
    },
  }).withTypeProvider<ZodTypeProvider>();
 
  // Configura os compiladores do Zod
  app.setValidatorCompiler(validatorCompiler);
  app.setSerializerCompiler(serializerCompiler);
 
  // Configura o Swagger com transformação de schemas Zod
  await app.register(fastifySwagger, {
    openapi: {
      info: {
        title: "API de Produtos",
        description: "API completa com Fastify e Zod",
        version: "1.0.0",
      },
      servers: [{ url: "http://localhost:3000" }],
    },
    transform: jsonSchemaTransform,
  });
 
  await app.register(fastifySwaggerUi, {
    routePrefix: "/docs",
  });
 
  // Registra as rotas
  await app.register(productRoutes, { prefix: "/api/products" });
 
  // Handler global de erros de validação
  app.setErrorHandler((error, request, reply) => {
    if (error.validation) {
      return reply.status(400).send({
        statusCode: 400,
        error: "Validation Error",
        message: "Os dados enviados são inválidos",
        details: error.validation,
      });
    }
 
    request.log.error(error);
    return reply.status(500).send({
      statusCode: 500,
      error: "Internal Server Error",
      message: "Ocorreu um erro inesperado",
    });
  });
 
  return app;
}
 
async function main() {
  const app = await buildServer();
 
  try {
    await app.listen({ port: 3000, host: "0.0.0.0" });
    console.log("Servidor rodando em http://localhost:3000");
    console.log("Documentação em http://localhost:3000/docs");
  } catch (err) {
    app.log.error(err);
    process.exit(1);
  }
}
 
main();

Implementando as rotas com validação completa

TYPESCRIPT
// src/routes/product.routes.ts
import { FastifyInstance } from "fastify";
import { ZodTypeProvider } from "fastify-type-provider-zod";
import { z } from "zod";
import { randomUUID } from "crypto";
import {
  ProductSchema,
  CreateProductSchema,
  UpdateProductSchema,
  ProductParamsSchema,
  ProductQuerySchema,
  Product,
} from "../schemas/product.schemas.js";
 
// Simulação de banco de dados em memória
const products: Product[] = [];
 
export async function productRoutes(app: FastifyInstance) {
  const server = app.withTypeProvider<ZodTypeProvider>();
 
  // GET /api/products - Listar produtos com filtros
  server.route({
    method: "GET",
    url: "/",
    schema: {
      summary: "Lista produtos com filtros e paginação",
      tags: ["Produtos"],
      querystring: ProductQuerySchema,
      response: {
        200: z.object({
          data: z.array(ProductSchema),
          total: z.number(),
          page: z.number(),
          limit: z.number(),
        }),
      },
    },
    handler: async (request, reply) => {
      // request.query já está tipado e validado automaticamente
      const { category, minPrice, maxPrice, page, limit } = request.query;
 
      let filtered = [...products];
 
      if (category) {
        filtered = filtered.filter((p) => p.category === category);
      }
      if (minPrice !== undefined) {
        filtered = filtered.filter((p) => p.price >= minPrice);
      }
      if (maxPrice !== undefined) {
        filtered = filtered.filter((p) => p.price <= maxPrice);
      }
 
      const start = (page - 1) * limit;
      const paginated = filtered.slice(start, start + limit);
 
      return {
        data: paginated,
        total: filtered.length,
        page,
        limit,
      };
    },
  });
 
  // POST /api/products - Criar produto
  server.route({
    method: "POST",
    url: "/",
    schema: {
      summary: "Cria um novo produto",
      tags: ["Produtos"],
      body: CreateProductSchema,
      response: {
        201: ProductSchema,
      },
    },
    handler: async (request, reply) => {
      // request.body é do tipo CreateProduct, sem necessidade de cast
      const now = new Date().toISOString();
      const product: Product = {
        id: randomUUID(),
        ...request.body,
        createdAt: now,
        updatedAt: now,
      };
 
      products.push(product);
      return reply.status(201).send(product);
    },
  });
 
  // PUT /api/products/:id - Atualizar produto
  server.route({
    method: "PUT",
    url: "/:id",
    schema: {
      summary: "Atualiza um produto existente",
      tags: ["Produtos"],
      params: ProductParamsSchema,
      body: UpdateProductSchema,
      response: {
        200: ProductSchema,
        404: z.object({
          statusCode: z.literal(404),
          message: z.string(),
        }),
      },
    },
    handler: async (request, reply) => {
      const { id } = request.params;
      const index = products.findIndex((p) => p.id === id);
 
      if (index === -1) {
        return reply.status(404).send({
          statusCode: 404 as const,
          message: `Produto com ID ${id} não encontrado`,
        });
      }
 
      products[index] = {
        ...products[index],
        ...request.body,
        updatedAt: new Date().toISOString(),
      };
 
      return products[index];
    },
  });
}

Neste exemplo, cada rota tem validação de entrada (body, params, querystring) e validação de saída (response). Se o handler retornar um objeto com campos extras que não estão no schema de resposta, o Fastify os remove automaticamente. Isso previne vazamento acidental de dados sensíveis.

Boas Práticas para Fastify com Zod em Produção

Organize schemas em módulos separados

Nunca defina schemas inline nas rotas. Crie um diretório schemas/ e agrupe por domínio. Isso facilita o reuso e permite compartilhar schemas entre rotas, testes e até com o frontend em monorepos.

Use schemas de resposta para segurança

O schema de response não serve apenas para documentação. Ele funciona como uma camada de serialização que remove campos não declarados. Se sua query de banco retorna um campo passwordHash acidentalmente, o schema de resposta garante que ele nunca chegue ao cliente.

Aproveite os métodos de composição do Zod

Os métodos .pick(), .omit(), .partial(), .extend() e .merge() são extremamente úteis. Defina um schema base para cada entidade e derive os schemas de criação, atualização e resposta a partir dele. Isso garante consistência e reduz manutenção.

Trate erros de validação de forma amigável

O error handler padrão do Fastify retorna erros de validação em um formato técnico. Sobrescreva o setErrorHandler para retornar mensagens claras e padronizadas. Em APIs públicas, evite expor detalhes internos de validação que possam revelar a estrutura dos seus dados.

Use z.coerce para query params e headers

Query params e headers sempre chegam como strings. O z.coerce.number(), z.coerce.boolean() e z.coerce.date() fazem a conversão antes da validação. Sem isso, você terá erros de tipo confusos em runtime.

Valide variáveis de ambiente com Zod

Uma prática que recomendo fortemente é usar Zod para validar as variáveis de ambiente no startup da aplicação:

TYPESCRIPT
// src/config/env.ts
import { z } from "zod";
 
const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "staging", "production"]),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
});
 
export const env = EnvSchema.parse(process.env);

Se alguma variável estiver faltando ou inválida, a aplicação falha imediatamente com uma mensagem clara, em vez de quebrar minutos depois em um ponto aleatório do código.

Conclusão

A integração entre Fastify e Zod representa um dos melhores padrões disponíveis hoje para construir APIs TypeScript. Você ganha validação robusta, tipos inferidos automaticamente, documentação Swagger gerada a partir dos mesmos schemas e uma camada de serialização que protege contra vazamento de dados.

O investimento inicial de configuração é mínimo comparado ao retorno. Depois que o setup está pronto, cada nova rota exige apenas a definição do schema e do handler, com tipagem completa do request ao response sem

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