Type-safe environment variables com Zod ou envalid

1. Introdução ao problema: variáveis de ambiente sem tipo

Em projetos TypeScript, process.env é tipado como { [key: string]: string | undefined }. Isso significa que qualquer acesso a uma variável de ambiente retorna string | undefined, mesmo que você saiba que ela existe. O problema se manifesta de várias formas:

// Exemplo clássico de problema em runtime
const port = process.env.PORT; // tipo: string | undefined
const server = app.listen(port); // erro silencioso: port pode ser undefined

// Conversão manual sem validação
const apiUrl = process.env.API_URL as string; // cast inseguro
const timeout = parseInt(process.env.TIMEOUT); // NaN silencioso

// Valores booleanos problemáticos
const isProduction = process.env.NODE_ENV === 'production'; // string comparada com string

Confiar apenas em .env.example não resolve o problema porque:
- O arquivo exemplo pode estar desatualizado
- Não há garantia de que o desenvolvedor copiou todas as variáveis
- TypeScript não valida o conteúdo do .env em tempo de compilação
- Erros só aparecem em runtime, frequentemente em produção

2. Zod: schema-first para validação rigorosa

Zod permite definir schemas que validam e transformam variáveis de ambiente com tipo seguro:

import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  PORT: z.coerce.number().positive().default(3000),
  DATABASE_URL: z.string().url(),
  REDIS_HOST: z.string().default('localhost'),
  ENABLE_FEATURE_X: z.coerce.boolean().default(false),
  API_RATE_LIMIT: z.coerce.number().int().min(1).max(1000),
});

// Parsing com tratamento de erro graceful
const result = envSchema.safeParse(process.env);

if (!result.success) {
  console.error('❌ Variáveis de ambiente inválidas:', result.error.format());
  process.exit(1);
}

const env = result.data;
// env agora tem tipos inferidos automaticamente
// env.PORT é number, env.NODE_ENV é 'development' | 'production' | 'test'

O safeParse é preferível ao parse porque permite tratamento de erro sem lançar exceções. O tipo inferido via z.infer pode ser exportado para uso em toda a aplicação:

export type Env = z.infer<typeof envSchema>;

3. Envalid: abordagem declarativa e leve

Envalid oferece uma API mais concisa para validação de variáveis de ambiente:

import { cleanEnv, str, num, bool, url, email, host } from 'envalid';

const env = cleanEnv(process.env, {
  NODE_ENV: str({ choices: ['development', 'production', 'test'] }),
  PORT: num({ default: 3000, devDefault: 8080 }),
  DATABASE_URL: url(),
  ADMIN_EMAIL: email({ default: 'admin@example.com' }),
  REDIS_HOST: host({ default: 'localhost' }),
  ENABLE_FEATURE_X: bool({ default: false, devDefault: true }),
  API_RATE_LIMIT: num({ default: 100, devDefault: 50 }),
});

// env já é tipado automaticamente
// env.PORT é number, env.NODE_ENV tem union type

Envalid expõe um objeto tipado sem necessidade de cast manual. A função cleanEnv já valida e transforma os valores, retornando um objeto com tipos corretos. Para ambientes de desenvolvimento, devDefault permite valores diferentes sem poluir o .env.

4. Comparação prática: Zod vs Envalid

Característica Zod Envalid
Tamanho (minified) ~12KB ~3KB
Flexibilidade Alta (transformações, refinamentos) Média (validadores predefinidos)
Curva de aprendizado Moderada Baixa
Validação condicional Suporte nativo Limitada
Integração com TypeScript Excelente (inferência automática) Boa

Quando usar cada um:

  • Envalid: Projetos pequenos a médios, APIs REST simples, aplicações com poucas variáveis de ambiente. Sua simplicidade reduz boilerplate.
// Exemplo com envalid para projeto pequeno
import { cleanEnv, str, num } from 'envalid';
export const env = cleanEnv(process.env, {
  PORT: num({ default: 3000 }),
  DATABASE_URL: str(),
});
  • Zod: Sistemas complexos com validação condicional, transformações customizadas ou schemas compartilhados entre frontend e backend.
// Zod permite refinamentos customizados
const envSchema = z.object({
  DATABASE_URL: z.string().url().refine(
    (url) => url.startsWith('postgres://') || url.startsWith('mysql://'),
    { message: 'URL deve ser PostgreSQL ou MySQL' }
  ),
  FEATURE_FLAGS: z.string().transform((str) => str.split(',')),
});

5. Integração com frameworks e build tools

A validação deve ocorrer no início da aplicação, antes de qualquer outro código:

// entry point (ex: src/index.ts)
import 'dotenv/config'; // carrega .env
import { env } from './config/env'; // valida imediatamente

// Se a validação falhar, o app nem inicia
console.log(`Servidor rodando na porta ${env.PORT}`);

Para Next.js, crie um arquivo de configuração separado:

// src/lib/env.ts (Next.js)
import { z } from 'zod';

const envSchema = z.object({
  NEXT_PUBLIC_API_URL: z.string().url(),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
});

// Validação em tempo de build
export const env = envSchema.parse(process.env);

No Vite, use import.meta.env com validação similar:

// src/env.ts (Vite)
import { z } from 'zod';

const clientSchema = z.object({
  VITE_API_URL: z.string().url(),
  VITE_ENABLE_ANALYTICS: z.coerce.boolean(),
});

export const env = clientSchema.parse(import.meta.env);

6. Type safety em tempo de compilação e runtime

Para garantir type safety completo, declare tipos globais:

// src/types/env.d.ts
import { z } from 'zod';

const envSchema = z.object({
  PORT: z.coerce.number(),
  DATABASE_URL: z.string().url(),
});

export type Env = z.infer<typeof envSchema>;

declare global {
  namespace NodeJS {
    interface ProcessEnv extends Env {}
  }
}

Use satisfies para garantir que o schema cubra todas as variáveis do .env:

import { z } from 'zod';

const envSchema = {
  PORT: z.coerce.number(),
  DATABASE_URL: z.string().url(),
  // TypeScript reclama se faltar alguma variável
} satisfies Record<string, z.ZodTypeAny>;

const parsed = z.object(envSchema).parse(process.env);

Isso elimina a necessidade de as string ou outros casts inseguros.

7. Boas práticas e armadilhas comuns

Nunca exponha schemas de validação no frontend — isso vaza a estrutura do seu ambiente:

// ❌ ERRADO: schema exposto no bundle do frontend
import { envSchema } from '../shared/schemas';

// ✅ CORRETO: apenas valores validados são exportados
export const env = envSchema.parse(process.env);

Lidando com variáveis opcionais sem perder strictness:

const envSchema = z.object({
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'),
  // optional() + default() garante que sempre terá um valor
});

Em monorepos, compartilhe schemas entre packages:

// packages/shared/src/env.ts
import { z } from 'zod';

export const sharedEnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  LOG_LEVEL: z.string().default('info'),
});

// packages/api/src/env.ts
import { sharedEnvSchema } from '@myorg/shared';
import { z } from 'zod';

const apiEnvSchema = sharedEnvSchema.extend({
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
});

8. Conclusão e próximos passos

Validar variáveis de ambiente com tipo seguro elimina uma classe inteira de bugs: erros de configuração que só apareceriam em produção. Tanto Zod quanto Envalid resolvem o problema, cada um com seu equilíbrio entre simplicidade e flexibilidade.

Próximos passos recomendados:
- Adicione ts-reset para melhorar a tipagem de process.env no escopo global
- Use dotenv-flow para gerenciar múltiplos ambientes (.env.development, .env.production)
- Explore o operador satisfies do TypeScript 4.9+ para validação em tempo de compilação
- Considere type-only imports para evitar código morto no bundle

A validação com tipo não é um luxo — é uma necessidade em qualquer aplicação TypeScript profissional. Invista alguns minutos na configuração inicial e evite horas de debugging em produção.

Referências