Type-safe environment variables without the boilerplate
Stop sprinkling process.env.FOO! across your codebase. Validate once, at boot, and hand the rest of your app a fully-typed config.
Every Node app eventually grows a tangle of process.env reads — a database URL here, a feature flag there, a port number cast with a hopeful Number(...). TypeScript can’t help you, because as far as it knows every one of those values is string | undefined. So you reach for the non-null assertion, and ship a footgun.
There’s a better shape for this, and it doesn’t need a validation library. The idea: parse your environment exactly once, when the process starts, into a frozen object with real types. If something is missing or malformed, the app refuses to boot — loudly, with a useful message — instead of failing three requests later in production.
The problem with reading env inline
Here’s the pattern you’ve almost certainly written. It looks innocent:
// scattered across the codebase, repeated, untyped
const url = process.env.DATABASE_URL!; // string, you hope
const port = Number(process.env.PORT); // NaN if unset
const debug = process.env.DEBUG === "true"; // "false"? "0"? who knows Three different failure modes, three different places to fix them, and not one of them will be caught at compile time. The ! tells TypeScript to trust you. It shouldn’t.
Environment variables are an input boundary, just like an HTTP request body. The rule at every boundary is the same: parse, don’t cast. Validate the untrusted shape into a trusted one, once.
Parse once, at boot
Instead, write a single function that reads the raw environment, checks every value, and returns a typed object. Edit the example below and run it — it executes right here:
The whole app now imports config and gets autocompletion, real types, and a guarantee: if this object exists, every field in it is valid. Delete DATABASE_URL above and run again — notice the process throws immediately, with the name of the thing that’s missing.
One source of truth
Because the parser runs at startup, the failure happens at the only time it’s cheap to fix: before you’ve served a single request. Compare the two timelines:
“A config error at boot is a deploy that never goes live. The same error read inline is a 500 at 3am.”
— the entire argument, in one sentence
- Inline reads fail lazily, per-code-path, often only under the exact request that touches them.
- A boot-time parse fails eagerly, once, with a message that names the variable.
- Your editor now knows
config.portis anumber— no casting, no guessing.
Wiring it into the container
This pattern pairs beautifully with Docker: declare the variables your image expects, and a missing one fails the container’s healthcheck instead of lurking. Try the commands in the simulated shell below:
Keep the parser dependency-free in small projects. When your schema grows past a dozen fields or needs nested shapes, reach for a runtime validator — but the boundary stays in exactly this one file.
Where to take it next
Once the boundary is a single function, everything good composes on top of it: load different files per environment, redact secrets from logs, or generate a .env.example straight from the parser. But the core idea never changes — cross the boundary once, and trust everything on the other side.
That’s the whole series in miniature. In part 3 we’ll do the same thing for untrusted JSON coming off the network.