Sua aplicação lança um TypeError três horas depois do início da noite de sexta-feira. O stack trace aponta para uma propriedade profundamente aninhada em um objeto de config. O valor é undefined. Alguém fez push de uma mudança no .env para produção sem atualizar a lógica de validação.
Isso não é um bug de runtime. É uma falha de política. Você deixou dados não confiáveis entrarem no seu sistema sem inspecioná-los primeiro.
Por que a validação de config em runtime é tarde demais
A maioria dos times valida config de forma reativa. Uma variável de ambiente ausente causa um crash. Uma string de URL inválida se propaga até que fetch() lance. Uma chave mal nomeada em um arquivo JSON de config permanece dormente até que a feature que a lê finalmente se ative, dias ou semanas depois.
O problema piora à medida que você adota linguagens específicas de domínio dentro de bounded contexts. Cada context tem sua própria forma de config. Um serviço de pagamentos se importa com STRIPE_WEBHOOK_SECRET. Um serviço de notificações se importa com SNS_TOPIC_ARN. As formas diferem, mas o modo de falha é o mesmo: config inválida entra no sistema, e então o sistema falha quando os dados inválidos finalmente são usados.
TypeScript não te salva aqui. process.env retorna string | undefined. Sua interface Config promete uma string, mas a interface é uma mentira até que algo a force.
A solução é validar a config no limite, antes que ela alcance o runtime.
Validação de schema no limite do sistema
Trate a config como input não confiável, porque é. Variáveis de ambiente, arquivos JSON e serviços de config remota devem todos passar por um validador de schemas antes que sua aplicação os use.
Esta é a forma que funciona:
- Defina um schema que corresponda às expectativas do seu domínio.
- Parseie a config bruta através desse schema.
- Use o resultado parseado e tipado em todo lugar.
Se o parsing falhar, a aplicação encerra imediatamente com um erro claro. Sem startup parcial. Sem objeto de config com campos faltantes derivando para o resto do seu código.
Aqui está um exemplo concreto usando Zod:
import { z } from "zod";
const PaymentConfig = z.object({
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string().min(20),
MAX_RETRY_ATTEMPTS: z
.string()
.transform((s) => parseInt(s, 10))
.pipe(z.number().min(1).max(10)),
});
const raw = {
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
MAX_RETRY_ATTEMPTS: process.env.MAX_RETRY_ATTEMPTS ?? "3",
};
const config = PaymentConfig.parse(raw);
Se STRIPE_SECRET_KEY estiver ausente, a aplicação lança antes de montar qualquer rota. Se MAX_RETRY_ATTEMPTS for "fifteen", o parse falha com um erro claro. O tipo de config após o parsing é exatamente o que PaymentConfig descreve, não string | undefined.
Schemas por contexto, não um objeto global
Quando você modela a config por bounded context, evita a tentação de despejar cada variável de ambiente em um único objeto Config que cada module importa. Esse objeto global cria acoplamento oculto. Uma mudança na config do serviço de notificações exige tocar um arquivo que o serviço de pagamentos também importa.
Em vez disso, cada bounded context define seu próprio schema:
// contexts/payment/config.ts
export const PaymentConfig = z.object({
STRIPE_SECRET_KEY: z.string(),
WEBHOOK_ENDPOINT: z.string().url(),
});
// contexts/notification/config.ts
export const NotificationConfig = z.object({
SNS_TOPIC_ARN: z.string().startsWith("arn:aws:sns:"),
RATE_LIMIT_PER_MINUTE: z.number().default(100),
});
Cada context parseia apenas o que precisa. O serviço de pagamentos não vê variáveis de notificação, e vice-versa. Isso espelha os limites de domínio que você já traçou no seu código.
Validação em tempo de build com tipos gerados
O parsing na inicialização captura a maioria dos problemas, mas você pode ir além. Se sua config vive em arquivos estáticos, valide-os em tempo de build.
Considere um arquivo de config JSON que define feature flags por ambiente:
{
"features": {
"newCheckout": {
"enabled": true,
"rolloutPercentage": 50
}
}
}
Defina um schema, então valide o arquivo JSON como parte do seu passo de build:
import { z } from "zod";
import featureFlags from "./feature-flags.json";
const FeatureFlagConfig = z.object({
features: z.record(
z.object({
enabled: z.boolean(),
rolloutPercentage: z.number().min(0).max(100),
})
),
});
// This runs during `tsc` or `vite build` if you import the result
export const validatedFlags = FeatureFlagConfig.parse(featureFlags);
TypeScript se recusará a compilar se o arquivo JSON estiver malformado. O erro surge em CI, não em produção.
Para variáveis de ambiente, que são intrinsecamente dados de runtime, você não pode validar completamente em tempo de build. Mas pode gerar um accessor type-safe que falha rapidamente na inicialização:
function env(name: string, schema: z.ZodTypeAny): z.infer<typeof schema> {
const value = process.env[name];
const result = schema.safeParse(value);
if (!result.success) {
console.error(`Invalid env var ${name}:`, result.error.flatten());
process.exit(1);
}
return result.data;
}
const dbUrl = env("DATABASE_URL", z.string().url());
Este padrão centraliza a falha. Há exatamente um lugar onde uma variável de ambiente ausente ou inválida causa um crash, e isso acontece antes que seu servidor comece a aceitar tráfego.
Trade-offs: rigor versus fricção de deploy
A validação de schemas não é de graça. O custo principal é a fricção de deploy.
Se seu schema for muito rigoroso, uma nova variável de ambiente adicionada ao staging mas ainda não adicionada à produção impedirá deploys. Um colega adiciona FEATURE_X_ENABLED=false ao .env de staging e faz push de código que a lê. A produção crasha na inicialização porque a variável falta lá.
Você tem duas maneiras de lidar com isso.
Primeiro, use defaults explícitos para variáveis que têm fallbacks seguros:
const config = z.object({
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
ENABLE_METRICS: z.string().transform((s) => s === "true").default("false"),
});
Segundo, separe claramente a config obrigatória da opcional. Campos obrigatórios devem representar coisas sem as quais a aplicação genuinamente não pode operar. Campos opcionais devem ter defaults ou ser tratados como features desativadas quando ausentes.
Outro trade-off é o tamanho da biblioteca de schemas. Zod adiciona aproximadamente 10KB ao seu bundle. Se você está em um ambiente restrito, considere Valibot ou ArkType, que oferecem APIs similares com menor footprint. O padrão importa mais que a biblioteca.
Quando tipos gerados não são suficientes
Alguns times geram tipos TypeScript a partir de seus arquivos de config e chamam isso de validado. Uma definição de tipo não impõe nada em runtime. Se um arquivo JSON muda de forma depois que os tipos foram gerados, TypeScript não vai capturar a menos que o arquivo seja importado e verificado durante a compilação.
O parsing em runtime fecha a lacuna. O schema é tanto o validador quanto a fonte da verdade para os tipos. Não há definição de tipo separada que possa ficar fora de sincronia.
Juntando tudo em um bounded context
Aqui está um padrão completo para um único contexto:
// contexts/billing/config.ts
import { z } from "zod";
const RawBillingConfig = z.object({
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string(),
INVOICE_GRACE_PERIOD_DAYS: z
.string()
.transform(Number)
.pipe(z.number().min(0)),
});
export type BillingConfig = z.infer<typeof RawBillingConfig>;
export function loadBillingConfig(): BillingConfig {
const raw = {
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
INVOICE_GRACE_PERIOD_DAYS: process.env.INVOICE_GRACE_PERIOD_DAYS,
};
const result = RawBillingConfig.safeParse(raw);
if (!result.success) {
const issues = result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`Billing config invalid:\n${issues}`);
}
return result.data;
}
Importe loadBillingConfig() no seu ponto de entrada, chame-o uma vez e passe o resultado para a inicialização do seu context. Nenhum module fora deste context deve ler process.env diretamente.
O que fazer em seguida
Audite sua codebase por leituras diretas de process.env fora de arquivos de config. Cada uma é uma potencial falha de runtime esperando o deploy certo para ser desencadeada.
Substitua-as por schemas por contexto. Escolha uma biblioteca, defina as formas e falhe rápido na inicialização. A primeira vez que uma variável de ambiente ausente encerre sua aplicação limpamente em CI em vez de lançar em produção, você saberá que o limite está funcionando.