Configuration Management

TypeScript Real World Applications — Configuration Management
TypeScript Real World Applications
Course 3 · Chapter 4 · Configuration Management

⚙️ Configuration Management

A missing environment variable is a bug like any other — except it usually surfaces in production, three requests deep, as an unhelpful "Cannot read properties of undefined." This chapter types configuration the same way Chapter 3 typed request bodies: validate the shape once, at startup, and let every other file trust it completely.

The Problem With process.env

Node's process.env is typed as { [key: string]: string | undefined } — every single value might be missing, and there's no way to know until runtime:

const port = process.env.PORT; // type: string | undefined const apiUrl = process.env.API_URL; // type: string | undefined app.listen(port); // ❌ TypeScript error: string | undefined isn't assignable to number // A quick "fix" — port! — just silences the compiler and defers the crash to runtime.

Silent Typos

process.env.DATBASE_URL (typo) just returns undefined — no error, no warning, until something fails downstream.

Everything Is a String

PORT=3000 arrives as the string "3000", not the number 3000 — every numeric or boolean value needs explicit parsing.

One Schema, One Type — Again

The same pattern from Chapter 3's request validation applies directly to configuration: define a schema once, derive the type from it, and parse process.env through it:

import { z } from "zod"; const ConfigSchema = z.object({ NODE_ENV: z.enum(["development", "production", "test"]), PORT: z.coerce.number().int().positive(), API_URL: z.string().url(), ENABLE_CACHE: z.coerce.boolean().default(false), }); type Config = z.infer<typeof ConfigSchema>;

z.coerce.number() and z.coerce.boolean() handle the "everything is a string" problem directly in the schema — "3000" becomes the number 3000, not just a validated string.

🚦 Validate at Startup, Fail Fast

The whole point is to move a missing/invalid config value from "crashes mid-request in production" to "crashes immediately on boot, with a clear message":

Lazy Access vs Startup Validation

Lazy Access

Config is read wherever it's needed, whenever it's needed.

// Somewhere deep in request handler #47, three weeks later: const url = process.env.API_URL!; // Crashes only when this code path runs.
Startup Validation

Config is parsed once, before the server even starts listening.

const config = ConfigSchema.parse(process.env); // Throws immediately on boot if anything is missing or invalid — // before a single request is ever handled.

A Typed Config Module

// config.ts import { z } from "zod"; const ConfigSchema = z.object({ NODE_ENV: z.enum(["development", "production", "test"]), PORT: z.coerce.number().int().positive(), DATABASE_URL: z.string().min(1), }); type Config = z.infer<typeof ConfigSchema>; function loadConfig(): Config { const result = ConfigSchema.safeParse(process.env); if (!result.success) { console.error("Invalid configuration:", result.error.format()); process.exit(1); // fail fast, before the app does anything else } return result.data; } // Exported once, imported everywhere — every file trusts `config` completely. export const config = loadConfig();

🔐 Secrets Management

Secrets are configuration too — but they need extra care that ordinary config doesn't:

Never Commit Secrets

.env holds local development secrets and is .gitignore'd; .env.example documents the required keys with placeholder values.

Production Secret Stores

AWS Secrets Manager, HashiCorp Vault, or your platform's built-in secrets — injected as environment variables at deploy time, never checked into the repo.

Separate Schema for Secrets

Splitting PublicConfig from Secrets at the type level makes it obvious which values are safe to log and which aren't.

Rotation

Config validated at startup also means a rotated secret is caught immediately on the next deploy, not silently ignored.

const PublicConfigSchema = z.object({ PORT: z.coerce.number(), API_URL: z.string().url(), }); const SecretsSchema = z.object({ DATABASE_PASSWORD: z.string().min(1), JWT_SIGNING_KEY: z.string().min(32), }); type PublicConfig = z.infer<typeof PublicConfigSchema>; type Secrets = z.infer<typeof SecretsSchema>; // A logging helper only ever accepts PublicConfig — Secrets can't be passed in by accident. function logStartupConfig(config: PublicConfig): void { console.log("Starting with config:", config); }

💻 Coding Challenges

Challenge 1: Type process.env

Write a ConfigSchema for NODE_ENV, PORT, and LOG_LEVEL (an enum of "debug" | "info" | "warn" | "error"), then derive a Config type from it.

Goal: Practice coercing string env vars into numbers/enums through a schema.

→ Solution

Challenge 2: Fail Fast on Boot

Write a loadConfig() function that parses process.env against your schema and calls process.exit(1) with a readable error message if validation fails.

Goal: Practice turning a runtime config problem into an immediate, loud startup failure instead of a silent one.

→ Solution

Challenge 3: Separate Public Config From Secrets

Split your schema into PublicConfigSchema and SecretsSchema, and write a function that logs only the public config, never the secrets.

Goal: Practice using separate types to make "safe to log" a compile-time guarantee.

→ Solution

⚠️ Gotcha: Logging the Whole Config Object

console.log(config) feels harmless during debugging — until config includes a database password or API key, and that log line ends up in a log aggregator (see the upcoming Logging & Observability chapter) that dozens of people can search. Keep secrets in a separate typed object from public config, and never pass the combined object to a logger.

🎯 What's Next

With configuration validated and secrets kept separate, the next chapter looks at what happens once the app is actually running: Logging & Observability — structured logging, log aggregation, and the basics of metrics and tracing for debugging production issues.