Validate Node.js Environment Variables with Zod and TypeScript
Stop reading process.env all over Node.js code. One env.ts file validated with Zod catches missing secrets at boot and gives the app typed TypeScript config.
This article is published in English.
Picture a Friday-afternoon deploy. The build passes, the server boots and everyone heads home. An hour later users report that logins are failing. It turns out that nobody set JWT_SECRET in the production environment. The app started happily and only broke when the first request touched the code that needed the missing value.
Reading process.env all over a codebase makes this kind of incident likely. What follows shows how to replace that habit with one validated, typed configuration module, using Zod and TypeScript.
The usual approach
Most Node.js code grabs configuration right where it's needed: a port in one file, a database string in another.
// Some random controller file
const port = process.env.PORT || 3000;
mongoose.connect(process.env.MONGO_URI);
It's convenient, and it has three real problems.
- No type safety. In TypeScript,
process.env.PORTisstring | undefined. If you want a number, every call site has to parse it by hand. - Silent failures. When
MONGO_URIis absent, the process doesn't crash at startup. It crashes whenever that particular line finally runs, possibly hours after a deploy that looked healthy. - No single source of truth. A newcomer has no list of the variables the app needs. Their only option is to search the whole repository for
process.env.
A single gatekeeper file
The cure is to stop touching process.env in business logic. A single module, call it env.ts, reads the environment once at startup, validates it and exports a strongly typed object. Everything else imports that object.
Zod, a TypeScript-first schema validation library, does the validation.
Install it
npm install zod
Write the schema
Put the module at src/config/env.ts and describe exactly what the environment must look like.
import dotenv from 'dotenv';
import { z } from 'zod';
// Load variables from .env file (if in development)
dotenv.config();
// 1. Define the schema
const envSchema = z.object({
PORT: z.string().transform(Number).default('3000'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
MONGO_URI: z.string(),
JWT_SECRET: z.string(),
JWT_EXPIRES_IN: z.string().default('1d'),
});
// 2. Parse the environment against the schema
const _env = envSchema.safeParse(process.env);
// 3. Fail fast if it's invalid
if (!_env.success) {
console.error('Invalid environment variables:', _env.error.format());
process.exit(1); // Crash the app immediately!
}
// 4. Export the typed, validated object
export const env = _env.data;
What the module does
- It declares expectations.
MONGO_URIis a required string.PORTarrives as a string, and.transform(Number)converts it, with a default of 3000.NODE_ENVhas to be one of three allowed values. - It fails fast.
safeParseruns the moment the file is imported, so import it at the very top of your entry point, whether that'sapp.tsorserver.ts. If any required variable is missing or malformed, the result reports failure, the error output says precisely what went wrong andprocess.exit(1)stops the process before it serves traffic. - It exports a typed object. On success, the parsed data carries the types Zod inferred, so
env.PORTis already a number.
From then on, any file can import env and use it.
What you get
- Autocomplete. Typing
env.in your editor lists every available variable. - No scattered fallbacks. The
|| "default_value"patterns disappear from controllers, because defaults live in the schema. - Built-in documentation. The schema is an accurate list of what the app needs to run, which also answers the newcomer's question about what belongs in
.env.
A missing secret now stops a deploy in seconds, at boot, with a clear message, instead of surfacing as an angry user an hour later.