Why Startup Validation
Why Startup Validation
Environment variables are untyped strings that may be absent, empty, or wrong:
const port = Number(process.env.PORT); // NaN if unset
fetch(process.env.DATABASE_URL); // "undefined" in a connection string
A missing variable should crash the process at boot with a message naming every problem — not hours later inside a rarely hit branch:
// config/env.ts — only module that reads process.env
export const env = loadEnv(); // throws a readable report on failure
export type Env = typeof env; // inferred — no parallel interface
Type the shape for editors:
declare global {
namespace NodeJS {
interface ProcessEnv { NODE_ENV: string; DATABASE_URL: string }
}
}
ProcessEnv typing serves only the config module — the app receives the validated object, never process.env.
A Concrete Zod Config Loader
A Concrete Zod Config Loader
import { z } from "zod";
import "dotenv/config"; // must run before parse
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_URL: z.string().url(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid environment:", parsed.error.issues
.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n"));
process.exit(1); // fail fast
}
export const env = Object.freeze(parsed.data);
Coercion handles PORT="8080"; the enum rejects LOG_LEVEL=verbose; defaults keep local dev light while production must supply DATABASE_URL. Object.freeze makes config read-only after boot.
Pitfalls and Interview Angles
Pitfalls and Interview Angles
- Import order: load
dotenv/configbefore the schema parse — later loads will not refresh values other modules already read. - Lazy re-reads: validating on every use reintroduces the original bug — one eager load, one frozen object.
- Secrets hygiene:
.envstays in.gitignore; real secrets come from the platform’s store. Validate shape in CI against.env.example. - Injection: pass
envinto constructors —new Server(env.PORT)— so tests construct{...env, PORT: 0}fakes without monkeypatchingprocess.env. - Environment differences: one schema, per-environment values; use
superRefinewithNODE_ENVfor prod-only requirements.
Interview angle: describe boot order (dotenv → schema parse → freeze → export), the failure output format, and why process.exit(1) beats throwing here (clear ops signal).
Quiz
1. When should process.env be validated?
2. Why z.coerce.number() for PORT?
3. Why export a frozen config object instead of reading process.env everywhere?
Flashcards
Question
Fail-fast config
Click to reveal answer
Answer
Parse and validate env at startup; exit(1) with issue list rather than failing mid-request.
Question
dotenv ordering
Click to reveal answer
Answer
Load .env before schema validation — later loads will not refresh already-read values.
Question
z.coerce for env
Click to reveal answer
Answer
Strings become numbers/booleans before validation ranges and enums apply.
Question
Config injection
Click to reveal answer
Answer
Pass the frozen env object into constructors — tests substitute fakes without patching globals.
Revision Notes
Key Takeaways
- 1. Env vars are untyped strings; validate shape once, at boot, and freeze the result
- 2. Enums, defaults, and coercion encode operational rules in the schema itself
- 3. Inject config — never scatter process.env reads through domain code
Interview Tips
- • Walk the exact boot sequence and show the human-readable error format you would print
- • Explain how CI proves the sample env parses without exposing real secrets
Cheat Sheet
dotenv → EnvSchema.safeParse(process.env) → issues list → exit(1) → Object.freeze; z.coerce PORT, z.enum LOG_LEVEL; inject env object, never process.env.