O processo começa, recebe uma requisição e só então descobre que PORT não é um número, que DATABASE_URL está vazio ou que uma chave obrigatória não foi configurada. O TypeScript não apontou o problema porque ele verifica o código, não o ambiente que será injetado no deploy.

A solução é criar uma fronteira pequena para a configuração: ler os valores crus, validar presença e formato em runtime, transformar o que precisa de outro tipo e exportar um único objeto para o restante da aplicação. Neste texto, o exemplo usa Zod, mas a divisão entre tipo e validação também funciona com uma função própria.

Para separar esse problema de outros limites de runtime, veja como tipos e validação convivem em um middleware Express. Aqui a entrada não é req. É o ambiente que existe antes de o servidor atender alguém.

Diagrama mostra variáveis de ambiente passando por um schema e um erro seguro antes de virar configuração tipada.

Em uma frase

  • Tipos do TypeScript ajudam depois que a configuração foi validada.
  • O schema deve ser aplicado uma vez na fronteira de entrada.
  • O código da aplicação deve importar env, não repetir process.env.
  • Erros de configuração podem mostrar a chave e a regra, nunca o segredo recebido.

O TypeScript consegue validar o ambiente sozinho?

Não. O TypeScript remove as anotações na compilação, e uma asserção não faz checagem em runtime, como explica o handbook em "Everyday Types". O Node.js expõe o ambiente por meio de process.env, um objeto preenchido pelo processo em execução, conforme a documentação de "Environment Variables". Esses são momentos diferentes.

Se você escreve process.env.PORT as unknown as number, o compilador passa a aceitar o valor, mas a string continua sendo uma string quando o programa roda. O mesmo vale para declarar uma interface ProcessEnv com todos os campos obrigatórios. Isso melhora autocomplete, mas não cria a variável no container nem testa o valor que o provedor injetou.

O contrato útil tem duas partes: uma entrada desconhecida e uma saída que o restante do sistema pode usar. O schema faz a prova em runtime. O tipo inferido descreve o resultado dessa prova para o código que vem depois.

Onde deve ficar a fronteira de configuração?

Coloque a leitura e a validação em um módulo de configuração, perto do ponto em que a aplicação inicia. O módulo deve exportar a configuração pronta e os outros módulos devem depender desse objeto. A documentação do dotenv descreve o carregamento de um arquivo .env para process.env; carregar valores e validar valores são responsabilidades distintas (dotenv, "README").

Em uma aplicação, a ordem pode ser simples:

  1. O loader escolhido carrega .env ou o runtime injeta as variáveis.
  2. createConfig valida o objeto cru.
  3. O módulo exporta env com números, URLs e opções já convertidos.
  4. O servidor, os workers e os adapters importam env em vez de ler process.env novamente.

Para um pacote reutilizável, não leia process.env durante o import. Receba um objeto de configuração na factory e valide esse argumento. Isso permite que a biblioteca rode em mais de um ambiente e facilita testes. A decisão é diferente para uma aplicação que controla seu próprio processo e para um pacote que será importado por outra aplicação.

Como criar um schema que devolve configuração tipada?

Defina o schema sobre o objeto cru e exporte uma função testável. O Zod documenta schemas para descrever dados, coerção para converter entradas e z.infer para obter o tipo correspondente (Zod, "Defining schemas"). Como variáveis do ambiente chegam como texto, a coerção precisa ser explícita.

O código abaixo é ilustrativo, mas completo. Ele não depende de uma interface global ProcessEnv, portanto não transforma uma promessa de compilação em uma garantia falsa:

// src/config/env.ts
import * as z from "zod";

const envSchema = z.object({
  NODE_ENV: z
    .enum(["development", "test", "production"])
    .default("development"),
  PORT: z.coerce.number().int().min(1).max(65_535).default(3000),
  DATABASE_URL: z.url(),
  API_KEY: z.string().min(1),
});

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

export function createConfig(
  input: Record<string, string | undefined>,
): Env {
  const result = envSchema.safeParse(input);

  if (!result.success) {
    const problems = result.error.issues.map((issue) => {
      const path = issue.path.join(".") || "root";
      return `${path}: ${issue.message}`;
    });

    throw new Error(
      `Invalid environment configuration:\n${problems.join("\n")}`,
    );
  }

  return result.data;
}

export const env = createConfig(process.env);

Depois, carregue o arquivo .env antes de importar esse módulo quando sua aplicação usar dotenv:

// src/main.ts
import "dotenv/config";
import { env } from "./config/env.js";

startServer({
  port: env.PORT,
  databaseUrl: env.DATABASE_URL,
});

Em versões do Node.js que carregam arquivos de ambiente por opção de linha de comando, você pode escolher o loader do próprio runtime. O ponto não muda: o schema ainda precisa decidir se uma string está presente, se tem o formato esperado e qual tipo a aplicação receberá.

Como validar sem vazar segredos nos logs?

Mostre a chave e a regra que falhou, não o valor recebido. O erro padrão de uma biblioteca pode incluir detalhes úteis para desenvolvimento, mas enviar um objeto de erro completo a um agregador pode registrar uma credencial ou uma URL com senha. O formatter do exemplo seleciona apenas path e message por esse motivo.

Também não teste uma chave real para provar o caminho inválido. Use valores artificiais e confirme a forma do erro:

const valid = createConfig({
  NODE_ENV: "test",
  PORT: "3001",
  DATABASE_URL: "https://example.com/database",
  API_KEY: "test-only-key",
});

if (valid.PORT !== 3001 || valid.NODE_ENV !== "test") {
  throw new Error("valid configuration was not transformed");
}

try {
  createConfig({
    NODE_ENV: "staging",
    PORT: "not-a-port",
    DATABASE_URL: "not-a-url",
    API_KEY: "",
  });
  throw new Error("invalid configuration was accepted");
} catch (error) {
  const message = error instanceof Error ? error.message : String(error);

  if (
    !message.includes("NODE_ENV") ||
    !message.includes("PORT") ||
    !message.includes("DATABASE_URL") ||
    !message.includes("API_KEY") ||
    message.includes("test-only-key")
  ) {
    throw new Error("configuration error was not safe or specific");
  }
}

Essa verificação é um teste de comportamento, não um benchmark. Ela prova duas propriedades locais do exemplo: a porta chega como número depois da validação e a mensagem inválida lista chaves sem imprimir o valor de API_KEY. Para um projeto real, rode o teste com o compilador e com o runtime que fará o deploy.

O que a validação inicial não consegue provar?

Ela consegue verificar presença, formato, faixa, enumeração e conversões que acontecem dentro do processo. Ela não consegue provar que uma credencial está ativa, que um banco responde ou que um bucket existe sem fazer uma chamada externa. Misturar essas verificações no boot faz a disponibilidade de outro serviço decidir se o processo pode iniciar.

Uma divisão mais previsível é validar forma e presença na inicialização e checar dependências remotas quando a integração for usada, com timeout, observabilidade e uma política de falha adequada. Para uma aplicação simples, a verificação remota pode acontecer no health check ou no primeiro uso. Para um pacote, a factory deve receber a configuração já escolhida pelo consumidor.

Esse limite também evita um erro comum: marcar toda chave como obrigatória no tipo global e assumir que isso representa produção. A aplicação pode ter ambientes com integrações opcionais. Modele essa diferença no schema, em vez de esconder a ausência com ! ou as.

Como verificar a configuração no projeto?

Comece com uma checagem estática e depois atravesse o comportamento real. O comando npx tsc --noEmit deve aceitar o uso de env.PORT como número e rejeitar uma propriedade inexistente em env. A execução com uma configuração válida deve iniciar. A execução com uma variável ausente, vazia ou inválida deve falhar antes de abrir a porta ou consumir a fila.

Se a configuração é usada no CI, valide o contrato sem imprimir o ambiente inteiro. O seu pipeline pode conferir nomes e formatos com valores de teste, enquanto o ambiente protegido injeta os segredos no job que precisa deles. Para encaixar essa checagem no fluxo de entrega, consulte a integração contínua para Node.js no GitLab.

Em um monorepo, confirme também quem é dono do arquivo env.ts. Se cada pacote ler process.env por conta própria, a aplicação volta a ter várias fronteiras. Project References no TypeScript ajuda a organizar dependências entre projetos, mas não substitui a decisão de onde a configuração será validada.

Perguntas frequentes

Devo estender NodeJS.ProcessEnv?

Somente se o autocomplete em acessos diretos trouxer um benefício real e a equipe mantiver a validação em runtime. Uma interface global não lê o ambiente nem converte strings em números. Exportar env a partir de um schema costuma deixar a fronteira mais visível.

Posso usar apenas as para tipar process.env?

Pode fazer o compilador aceitar o código, mas a asserção não altera o valor nem cria uma checagem em runtime. Use-a apenas depois de uma prova que o programa realmente executa. Para configuração, essa prova deve acontecer na fronteira de entrada.

Zod é obrigatório?

Não. Uma função própria com typeof, URL e verificações de faixa pode ser suficiente. O Zod reduz o código repetido e infere o tipo a partir do schema. A escolha da biblioteca é secundária; o contrato principal é validar uma vez e exportar o resultado.

Conclusão

Variáveis de ambiente são entrada externa, mesmo quando a equipe da aplicação é quem as configura. O TypeScript pode descrever a configuração depois da validação, mas não consegue inspecionar o container, o job ou o painel do provedor.

Crie uma fronteira única, valide o objeto cru, transforme os tipos e exporte um resultado pequeno. Faça os erros indicarem a chave e a regra sem repetir segredos. Depois prove o caminho válido e o inválido com o mesmo runtime que executará a aplicação.

Fontes consultadas