Environment variable schema validation at startup with typed access
Environment variables are validated at application startup. If any required variable is missing or malformed, the application must crash immediately with a clear error message. Invalid configuration must never reach running code.
The application defines a schema for its environment variables. At startup, the schema is evaluated against the actual environment. If validation fails, the process exits with a non-zero code and logs exactly which variables failed and why.
// Startup behavior:
// 1. Load environment variables
// 2. Validate against schema
// 3. If invalid: log errors, crash immediately
// 4. If valid: export the validated, typed object
A misconfigured application is worse than a stopped application. Running with a missing database URL will crash later — probably under load, probably with a confusing error, probably at 2 AM. Crashing at startup makes the problem obvious, immediate, and fixable before any user is affected.
After validation, the application exports a single typed object containing all validated environment variables. Application code imports this object — never reads process.env directly.
// Bad: raw access scattered throughout the codebase
const dbUrl = process.env.DATABASE_URL; // string | undefined — is it set? is it valid?
// Good: validated access through typed object
import { env } from "./env";
const dbUrl = env.DATABASE_URL; // string — guaranteed to exist and match the schema
The environment schema should handle:
// Pseudocode schema
{
NODE_ENV: enum("development", "production", "test"),
PORT: number().default(3000),
DATABASE_URL: string().url().required(),
SESSION_SECRET: string().min(32).required(),
LOG_LEVEL: enum("fatal", "error", "warn", "info", "debug", "trace").default("info"),
ALLOWED_ORIGINS: string().transform(s => s.split(",")).default(""),
}
Every project includes a .env.example file that documents every environment variable the application uses. This file:
# Database
DATABASE_URL=postgresql://localhost:5432/myapp # Required. Connection string for the primary database.
# Auth
SESSION_SECRET=change-me-to-a-random-string-at-least-32-chars # Required. Must be at least 32 characters.
# Server
PORT=3000 # Optional. Defaults to 3000.
NODE_ENV=development # Optional. One of: development, production, test.
LOG_LEVEL=info # Optional. One of: fatal, error, warn, info, debug, trace.
# External Services
SMTP_HOST= # Optional. Required for email features.
SMTP_PORT=587 # Optional. Defaults to 587.
When adding a new environment variable:
.env.example with documentation and example value.env locally (and any deployment configurations)Variables containing secrets (API keys, tokens, passwords) get extra treatment:
.env.exampleThe default pattern validates eagerly at module load — the app crashes if any env var is missing. This is correct for the main application.
For standalone scripts (seed scripts, migrations, CLI tools) that only need a subset of env vars, use a lazy pattern:
seedEnvSchema with only DATABASE_URL).partial() on the main schema and validate only what you needenv.ts in scripts that don't need all variablesThe principle remains: validate at startup, crash if invalid. But "startup" means the script's startup, and "invalid" means the vars the script actually needs.
process.env.env.example documents every variable with comments and example values