El proceso arranca, recibe una petición y solo entonces descubre que PORT no es un número, que DATABASE_URL está vacío o que nunca se configuró una clave obligatoria. TypeScript no señaló el problema porque comprueba el código, no el entorno que se inyectará durante el despliegue.

La solución es crear una frontera pequeña para la configuración: leer valores sin procesar, validar su presencia y formato en runtime, transformar lo que necesite otro tipo y exportar un único objeto para el resto de la aplicación. Este texto usa Zod, pero la separación entre tipos y validación también funciona con una función propia.

Para separar este problema de otros límites de runtime, consulta cómo conviven los tipos y las comprobaciones en un middleware de Express. Aquí la entrada no es req. Es el entorno que existe antes de que el servidor atienda a alguien.

Diagrama que muestra variables de entorno pasando por un schema y un error seguro antes de convertirse en configuración tipada.

En una frase

  • Los tipos de TypeScript ayudan después de validar la configuración.
  • Aplica el schema una sola vez en la frontera de entrada.
  • El código de la aplicación debe importar env, no repetir lecturas de process.env.
  • Un error de configuración puede mostrar la clave y la regla, nunca el secreto recibido.

¿Puede TypeScript validar el entorno por sí solo?

No. TypeScript elimina las anotaciones durante la compilación, y una aserción no comprueba nada en runtime, como explica el manual en "Everyday Types". Node.js expone el entorno mediante process.env, un objeto que el proceso en ejecución rellena, según la documentación de "Environment Variables". Son momentos distintos.

Si escribes process.env.PORT as unknown as number, el compilador acepta el valor, pero el string sigue siendo un string cuando se ejecuta el programa. Lo mismo ocurre si declaras todos los campos de ProcessEnv como obligatorios. Eso mejora el autocompletado, pero no crea una variable en el contenedor ni comprueba el valor que inyectó el proveedor.

El contrato útil tiene dos partes: una entrada desconocida y una salida que el resto del sistema puede utilizar. El schema realiza la prueba en runtime. El tipo inferido describe el resultado de esa prueba para el código que viene después.

¿Dónde debe vivir la frontera de configuración?

Coloca la carga y la validación en un módulo de configuración cerca del inicio de la aplicación. El módulo debe exportar una configuración lista para usar y los demás módulos deben depender de ese objeto. La documentación de dotenv describe cómo cargar un archivo .env en process.env; cargar valores y validarlos son responsabilidades separadas (dotenv, "README").

En una aplicación, el orden puede ser sencillo:

  1. El loader elegido lee .env, o el runtime inyecta las variables.
  2. createConfig valida el objeto sin procesar.
  3. El módulo exporta env con números, URLs y opciones ya transformados.
  4. El servidor, los workers y los adapters importan env en vez de volver a leer process.env.

En un paquete reutilizable, no leas process.env durante el import. Acepta un objeto de configuración en la factory y valida ese argumento. Así la biblioteca puede ejecutarse en más de un entorno y las pruebas son más sencillas. La decisión es distinta para una aplicación que controla su propio proceso y para un paquete que otra aplicación importa.

¿Cómo crear un schema que devuelva configuración tipada?

Define el schema sobre el objeto sin procesar y exporta una función que puedas probar. Zod documenta schemas para describir datos, coerción para convertir entradas y z.infer para obtener el tipo correspondiente (Zod, "Defining schemas"). Las variables de entorno llegan como texto, así que la conversión debe ser explícita.

El código siguiente es ilustrativo, pero está completo. No depende de una interfaz global ProcessEnv, por lo que no convierte una promesa de compilación en una garantía falsa en runtime:

// 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);

Después carga .env antes de importar este módulo cuando la aplicación use dotenv:

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

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

En las versiones de Node.js que cargan archivos de entorno mediante una opción de línea de comandos, puedes elegir el loader del propio runtime. El punto sigue siendo el mismo: el schema decide si el string está presente, tiene el formato esperado y se convierte en el tipo que recibirá la aplicación.

¿Cómo validar sin filtrar secretos en los logs?

Muestra la clave y la regla que falló, no el valor recibido. El error predeterminado de una biblioteca puede contener detalles útiles durante el desarrollo, pero enviar el objeto de error completo a un agregador puede registrar una credencial o una URL con contraseña. Por eso el formatter del ejemplo selecciona solo path y message.

Tampoco uses una clave real para demostrar el camino inválido. Usa valores artificiales y comprueba la forma del error:

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");
  }
}

Esto es una comprobación de comportamiento, no un benchmark. Demuestra dos propiedades locales del ejemplo: el puerto se convierte en número después de la validación y el mensaje inválido enumera las claves sin imprimir el valor de API_KEY. En un proyecto real, ejecuta la prueba con el compilador y el runtime del despliegue.

¿Qué no puede demostrar la validación inicial?

Puede comprobar presencia, formato, rangos, valores enumerados y conversiones que ocurren dentro del proceso. No puede demostrar que una credencial está activa, que una base de datos responde o que existe un bucket sin hacer una llamada externa. Poner esas comprobaciones en el arranque hace que la disponibilidad de otro servicio decida si el proceso puede comenzar.

Una separación más predecible consiste en validar forma y presencia al iniciar, y comprobar las dependencias remotas cuando se usa la integración, con timeout, observabilidad y una política de fallo adecuada. En una aplicación sencilla, esa comprobación remota puede ocurrir en un health check o en el primer uso. En un paquete, la factory debe recibir la configuración elegida por el consumidor.

Este límite también evita un error común: marcar todas las claves como obligatorias en el tipo global y tratar eso como la verdad de producción. Una aplicación puede tener entornos con integraciones opcionales. Modela esa diferencia en el schema en vez de ocultar la ausencia con ! o as.

¿Cómo verificar la configuración en el proyecto?

Empieza con una comprobación estática y después prueba el comportamiento real. npx tsc --noEmit debe aceptar env.PORT como número y rechazar una propiedad que no exista en env. Ejecutar con una configuración válida debería iniciar. Ejecutar con una variable ausente, vacía o inválida debería fallar antes de abrir un puerto o consumir una cola.

Si la configuración se usa en CI, valida el contrato sin imprimir todo el entorno. El pipeline puede comprobar nombres y formatos con valores de prueba mientras el entorno protegido inyecta secretos en el job que los necesita. Para integrar esa comprobación en la entrega, consulta la integración continua de Node.js en GitLab.

En un monorepo, decide también quién es responsable de env.ts. Si cada paquete lee process.env por su cuenta, la aplicación vuelve a tener varias fronteras. Project References de TypeScript en un monorepo ayuda a organizar las dependencias entre proyectos, pero no decide dónde se valida la configuración.

Preguntas frecuentes

¿Debo extender NodeJS.ProcessEnv?

Solo si el autocompletado del acceso directo aporta un beneficio real y el equipo mantiene la validación en runtime. Una interfaz global no lee el entorno ni convierte strings en números. Exportar env desde un schema suele hacer más visible la frontera.

¿Puedo usar solo as para tipar process.env?

Puede hacer que el compilador acepte el código, pero la aserción no cambia el valor ni crea una comprobación en runtime. Úsala solo después de una prueba que el programa realmente ejecute. Para la configuración, esa prueba debería ocurrir en la frontera de entrada.

¿Zod es obligatorio?

No. Una función propia con typeof, URL y comprobaciones de rango puede ser suficiente. Zod reduce el código repetido e infiere el tipo desde el schema. La biblioteca es secundaria; el contrato principal es validar una vez y exportar el resultado.

Conclusión

Las variables de entorno son entradas externas, aunque el equipo de la aplicación las configure. TypeScript puede describir la configuración después de validarla, pero no puede inspeccionar el contenedor, el job o el panel del proveedor.

Crea una sola frontera, valida el objeto sin procesar, transforma los tipos y exporta un resultado pequeño. Haz que los errores indiquen la clave y la regla sin repetir secretos. Después demuestra los caminos válido e inválido con el mismo runtime que ejecutará la aplicación.

Fuentes consultadas