Destino Dev

Blog

Config type-safe em Node.js com TypeScript: Zod, generics e utility types

Por John Pires · 03 de abril de 2026

Config type-safe em Node.js com TypeScript: Zod, generics e utility types

O que é e por que importa

Definição direta

Config type-safe é a prática de ler valores de configuração (variáveis de ambiente, arquivos .env, JSON, etc.) e transformá-los em um objeto tipado de maneira que o TypeScript saiba, em tempo de compilação, quais campos existem, seus tipos e possivelmente valores padrão. Em runtime, validações garantem que os valores recebidos seguem as regras esperadas.

Por que isso importa no dia a dia

Sem validação e tipagem forte, bugs simples acontecem: uma porta lida como string, uma URL faltando, credenciais nulas causando erro em produção. Com validação e tipagem você reduz surpresas, melhora a experiência do desenvolvedor e evita erros caros em produção. A segurança aqui é sobre confiabilidade do sistema, não apenas sobre segurança de dados.

Usar bibliotecas como Zod permite declarar uma fonte única de verdade (schema) que serve tanto para validação em runtime quanto para inferir tipos estáticos no TypeScript. Generics e utility types ajudam a escrever funções reutilizáveis e evitar duplicação entre validação e tipo.

Conceitos fundamentais com analogias

Config como contrato

Pense em configuração como um contrato entre a sua aplicação e o ambiente. O contrato lista chaves, tipos e regras. Quando você usa Zod, você escreve esse contrato em código: ele valida o que chega e o TypeScript pode ler esse contrato para inferir tipos.

Tipos em compile-time vs validação em runtime

TypeScript protege você enquanto escreve código (compile-time). Mas durante execução, apenas JavaScript roda — e valores externos (process.env) chegam como strings. Zod atua em runtime, convertendo/checando esses valores. A analogia: TypeScript é a planta do prédio; Zod inspeciona se o que foi construído segue a planta.

Generics permitem criar funções que trabalham com qualquer schema Zod sem perder tipos, como um molde reutilizável que aceita diferentes plantas e retorna objetos corretos conforme cada planta.

Passo a passo com codigo comentado

Preparação do projeto

Comece com um projeto Node + TypeScript. Instale dependências essenciais: zod e dotenv. Não é obrigatório, mas dotenv facilita carregar variáveis locais durante desenvolvimento.

Exemplos de comandos (não é código TypeScript):

  1. npm init -y
  2. npm install zod dotenv
  3. npm install -D typescript ts-node @types/node

Schema Zod básico e inferência de tipo

Agora criaremos o schema. A ideia: declarar como cada variável deve ser tratada e usar z.infer para extrair o tipo automaticamente.

Contexto: este arquivo define o schema de configuração e extrai o tipo TypeScript correspondente.

import { z } from 'zod'

// Definimos o schema com zod
const EnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.preprocess((v) => {
    // convertendo string para number quando necessário
    if (typeof v === 'string' && v.trim() !== '') return Number(v)
    return v
  }, z.number().int().positive().default(3000)),
  DATABASE_URL: z.string().min(1),
  ENABLE_FEATURE_X: z.preprocess((v) => {
    if (typeof v === 'string') return v === 'true'
    return Boolean(v)
  }, z.boolean().default(false))
})

// Inferimos o tipo TypeScript automaticamente a partir do schema
export type Env = z.infer<typeof EnvSchema>

export { EnvSchema }

O que acontece: o schema transforma strings em numbers/booleans conforme necessário, aplica defaults e exige campos obrigatórios. O tipo Env é derivado do schema, garantindo que o TypeScript opere com o formato validado.

Loader genérico que retorna tipos derivados do schema

Contexto: queremos uma função reutilizável que receba qualquer schema Zod e retorne o objeto validado e tipado. Usaremos generics para que the return type seja z.infer do schema recebido.

import { ZodType, ZodTypeAny } from 'zod'
import { EnvSchema } from './schema'

// Função genérica: aceita qualquer schema Zod e retorna o tipo inferido desse schema
export function loadConfig<T extends ZodTypeAny>(schema: T, env: Record<string, unknown> = process.env) {
  const result = schema.safeParse(env)
  if (!result.success) {
    // Em aplicações reais você pode logar erros formatados e falhar o startup
    const formatted = result.error.format()
    throw new Error('Config validation error: ' + JSON.stringify(formatted))
  }
  return result.data as z.infer<T>
}

// Uso específico
export const config = loadConfig(EnvSchema)

O que acontece: loadConfig valida env usando o schema passado. Se falhar, lança erro com detalhes; se passar, retorna um objeto tipado. O cast final ajuda o TypeScript a entender o retorno como z.infer do schema.

Exemplo pratico completo

Contexto do exemplo

Vamos montar uma aplicação mínima que expõe uma rota health e inicia um servidor Express usando as configurações tipadas. O objetivo é mostrar código real que roda e consome o config validado.

Implementação completa (server.ts)

Contexto: arquivo que inicializa dotenv, carrega config e inicia Express. Inclui tratamento de erro de validação.

import 'dotenv/config' // carrega variáveis do .env para process.env
import express from 'express'
import { config } from './config' // import do loader genérico que retorna o objeto tipado

const app = express()

app.get('/health', (req, res) => {
  res.json({ ok: true, env: config.NODE_ENV })
})

const port = config.PORT
app.listen(port, () => {
  console.log('Server running on port', port)
})

O que acontece: ao importar config, o loader já valida process.env conforme EnvSchema. Quando o servidor sobe, o TypeScript garante que config.PORT é number e config.DATABASE_URL é string, evitando conversões manuais espalhadas.

Um exemplo de teste rápido

Contexto: às vezes você quer testar o loader em isolamento com valores falsos para simular diferentes ambientes. Isso é simples com nossa função genérica que aceita um 'env' override.

import { loadConfig } from './loader'
import { EnvSchema } from './schema'

const fakeEnv = {
  NODE_ENV: 'test',
  PORT: '4000',
  DATABASE_URL: 'postgres://local',
  ENABLE_FEATURE_X: 'true'
}

const cfg = loadConfig(EnvSchema, fakeEnv)
console.log(typeof cfg.PORT, cfg.PORT, cfg.ENABLE_FEATURE_X)
// Saída esperada: 'number' 4000 true

O que acontece: o loader converte PORT para number e ENABLE_FEATURE_X para boolean conforme definido no schema. Isso permite escrever testes onde você controla o ambiente e verifica comportamento sem mexer em process.env globalmente.

Erros comuns, armadilhas e dicas práticas

Erros comuns

Listo os erros mais frequentes que vejo em projetos iniciais:

  • Confiar que process.env já tem tipos corretos — todas as variáveis chegam como string ou undefined.
  • Validar no começo mas continuar usando process.env diretamente no resto do código.
  • Fazer parse no momento do import do módulo que define config pode dificultar testes isolados e reinicialização de módulos.
  • Usar any para contornar tipos em vez de ajustar o schema e tipos corretamente.

Dicas práticas

Preferir safeParse em desenvolvimento para obter erros formatados. Use mensagens claras para o time dev e logs legíveis em produção.

Se o seu config falhar na inicialização, é melhor a aplicação não subir com um estado parcial — falhe rápido e com mensagem clara.

Outra armadilha: defaults no schema devem refletir valores sensíveis (ex: não coloque secrets padrão). Use defaults apenas para conveniências como porta ou flags de feature.

Proximos passos para aprofundar

Integração com CI e checks

Valide o schema em pipeline com conjuntos de variáveis por ambiente. Você pode rodar scripts que carregam schemas e validam arquivos .env.example ou arquivos de secret management. Isso evita mudanças em produção que quebrem o startup.

Outras bibliotecas e trade-offs

Zod é ótimo para DX e integração com TypeScript. Alternativas: io-ts (mais funcional), yup (menor integração com tipos inferidos automáticos). Avalie se quer runtime menor ou APIs diferentes. Zod costuma ser a escolha pragmática em projetos TypeScript atuais.

Estudar utility types do TypeScript ajuda a criar variantes do tipo de config: por exemplo, criar uma versão parcial para testes ou uma versão só com chaves sensíveis para logs.

Conclusão prática

Resumo das ações recomendadas

Para aplicar config type-safe em um projeto Node + TypeScript, siga estes passos práticos: 1) declare schemas Zod para todo config; 2) crie um loader genérico que valida e retorna tipos inferidos; 3) passe a usar apenas o objeto validado no restante da aplicação; 4) rode validações no CI para garantir que variantes de ambiente permanecem consistentes.

Fechamento e próximos comandos

Pratique convertendo um arquivo .env do projeto para um schema Zod e substitua usos diretos de process.env por import do config validado. Em seguida, adicione um job no CI que executa um script de validação para .env.example e falhe o build se algo estiver faltando.

Seguir esses passos reduz bugs de tipo, melhora a previsibilidade do sistema e ainda mantém o TypeScript e Zod como fonte única de verdade para configuração.

Se quiser, posso gerar um template de projeto com todos os arquivos mostrados (schema, loader, server, scripts de CI) pronto para clonar e rodar: diga o nome do gerenciador de pacotes e eu monto os scripts.

Assuntos: TypeScript, Node.js, zod, generics, config

Leia também