Guides/ TypeScript / At the boundaries
Series · TypeScript at the boundaries
Part 2 of 4
●●○○

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.

9 min read TypeScript
A config object you can trust — validated once, typed everywhere.

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:

db.ts
// 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.

Why

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:

env.js Node

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.port is a number — no casting, no guessing.
Fig 1. Untrusted env in, frozen typed config out — the boundary crossed exactly once.

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:

run with env
# the image expects DATABASE_URL, PORT, DEBUG. try 'docker run app'
~/app $
Tip

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.