Tu aplicación lanza un TypeError tres horas después de empezar el viernes por la noche. El stack trace apunta a una propiedad profundamente anidada en un objeto de config. El valor es undefined. Alguien hizo push de un cambio en .env a producción sin actualizar la lógica de validación.
Esto no es un bug de runtime. Es un fallo de política. Dejaste que datos no confiables entraran en tu sistema sin inspeccionarlos primero.
Por qué la validación de config en runtime es demasiado tarde
La mayoría de los equipos validan la config de forma reactiva. Una variable de entorno falta causa un crash. Un string de URL inválido se propaga hasta que fetch() lanza. Una key mal nombrada en un archivo JSON de config permanece dormida hasta que la feature que la lee finalmente se activa, días o semanas después.
El problema empeora a medida que adoptas lenguajes específicos de dominio dentro de bounded contexts. Cada context tiene su propia forma de config. Un servicio de pagos se preocupa por STRIPE_WEBHOOK_SECRET. Un servicio de notificaciones se preocupa por SNS_TOPIC_ARN. Las formas difieren, pero el modo de fallo es el mismo: la config inválida entra en el sistema, y luego el sistema falla cuando los datos inválidos finalmente se usan.
TypeScript no te salva aquí. process.env devuelve string | undefined. Tu interfaz Config promete un string, pero la interfaz es una mentira hasta que algo la haga cumplir.
La solución es validar la config en el límite, antes de que llegue al runtime.
Validación de esquemas en el límite del sistema
Trata la config como input no confiable, porque lo es. Las variables de entorno, los archivos JSON y los servicios de config remota deben pasar por un validador de esquemas antes de que tu aplicación los use.
Esta es la forma que funciona:
- Define un esquema que coincida con las expectativas de tu dominio.
- Parsea la config cruda a través de ese esquema.
- Usa el resultado parseado y tipado en todas partes.
Si el parsing falla, la aplicación se cierra inmediatamente con un error claro. No hay startup parcial. No hay objeto de config con campos faltantes que se desvíe hacia el resto de tu código.
Aquí hay un ejemplo 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);
Si falta STRIPE_SECRET_KEY, la aplicación lanza antes de montar cualquier ruta. Si MAX_RETRY_ATTEMPTS es "fifteen", el parse falla con un error claro. El tipo de config después del parsing es exactamente lo que PaymentConfig describe, no string | undefined.
Esquemas por contexto, no un objeto global
Cuando modelas la config por bounded context, evitas la tentación de volcar cada variable de entorno en un único objeto Config que cada module importa. Ese objeto global crea acoplamiento oculto. Un cambio en la config del servicio de notificaciones requiere tocar un archivo que el servicio de pagos también importa.
En su lugar, cada bounded context define su propio esquema:
// 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 parsea solo lo que necesita. El servicio de pagos no ve variables de notificación, y viceversa. Esto refleja los límites de dominio que ya has tracing en tu código.
Validación en tiempo de build con tipos generados
El parsing al inicio atrapa la mayoría de los problemas, pero puedes ir más allá. Si tu config vive en archivos estáticos, validarlos en tiempo de build.
Considera un archivo de config JSON que define feature flags por entorno:
{
"features": {
"newCheckout": {
"enabled": true,
"rolloutPercentage": 50
}
}
}
Define un esquema, luego valida el archivo JSON como parte de tu paso 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 negará a compilar si el archivo JSON está malformado. El error surge en CI, no en producción.
Para variables de entorno, que son inherentemente datos de runtime, no puedes validar completamente en tiempo de build. Pero puedes generar un accessor type-safe que falle rápido al inicio:
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 patrón centraliza el fallo. Hay exactamente un lugar donde una variable de entorno faltante o inválida causa un crash, y sucede antes de que tu servidor empiece a aceptar tráfico.
Compromisos: rigor versus fricción de despliegue
La validación de esquemas no es gratuita. El costo principal es la fricción de despliegue.
Si tu esquema es demasiado estricto, una nueva variable de entorno añadida a staging pero aún no a producción evitará los deploys. Un compañero añade FEATURE_X_ENABLED=false al .env de staging y hace push de código que la lee. Producción se cae al inicio porque la variable falta allí.
Tienes dos formas de manejar esto.
Primero, usa defaults explícitos para variables que tienen 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, separa claramente la config requerida de la opcional. Los campos requeridos deben representar cosas sin las que la aplicación genuinamente no puede operar. Los campos opcionales deben tener defaults o ser manejados como features desactivadas cuando están ausentes.
Otro compromiso es el tamaño de la librería de esquemas. Zod añade aproximadamente 10KB a tu bundle. Si estás en un entorno restringido, considera Valibot o ArkType, que ofrecen APIs similares con menor footprint. El patrón importa más que la librería.
Cuándo los tipos generados no son suficientes
Algunos equipos generan tipos TypeScript desde sus archivos de config y lo llaman validado. Una definición de tipo no fuerza nada en runtime. Si un archivo JSON cambia de forma después de que los tipos fueron generados, TypeScript no lo atrapará a menos que el archivo sea importado y verificado durante la compilación.
El parsing en runtime cierra la brecha. El esquema es tanto el validador como la fuente de verdad para los tipos. No hay una definición de tipo separada que pueda desincronizarse.
Poniéndolo junto en un bounded context
Aquí está un patrón completo para un contexto único:
// 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;
}
Importa loadBillingConfig() en tu punto de entrada, llámalo una vez y pasa el resultado a la inicialización de tu context. Ningún module fuera de este context debería leer process.env directamente.
Qué hacer a continuación
Audita tu codebase en busca de lecturas directas de process.env fuera de archivos de config. Cada una es un potencial fallo de runtime esperando el deployment correcto para activarse.
Reemplázalas con esquemas por contexto. Elige una librería, define las formas y falla rápido al inicio. La primera vez que una variable de entorno faltante cierre limpiamente tu aplicación en CI en lugar de lanzar en producción, sabrás que el límite está funcionando.