Skip to content
TypeScript ts node 3 min read

Typing Environment Variables

Node exposes configuration through process.env, but @types/node types every value as string | undefined. That is technically honest — any variable might be missing — yet it makes config access verbose and offers no guarantee that required variables exist or hold valid values. There are two complementary techniques: augment the ProcessEnv interface so process.env autocompletes, and build a validated env module that parses and narrows your configuration once at startup. This page covers both, and explains why the validation approach is the safer default.

Why process.env is awkward

Every property on process.env is string | undefined, so you cannot use a value without first checking it, and numbers and booleans need manual conversion. There is no compile-time check that DATABASE_URL was actually set.

const port = process.env.PORT;        // string | undefined
const url = process.env.DATABASE_URL; // string | undefined
// const n: number = process.env.PORT; // ❌ Error: string | undefined not assignable to number

This is correct but unergonomic. The two patterns below trade some boilerplate for either better autocomplete or genuine runtime guarantees.

Augmenting ProcessEnv

You can extend Node’s ProcessEnv interface through declaration merging so your known variables appear with autocomplete. Place this in a .d.ts file included by your tsconfig.

// src/types/env.d.ts
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      NODE_ENV: "development" | "production" | "test";
      PORT: string;
      DATABASE_URL: string;
    }
  }
}

export {};

After this, process.env.PORT is typed as string (not string | undefined) and typos like process.env.PROT are flagged. The export {} keeps the file a module so declare global augments the existing namespace.

Augmentation only changes types, not reality. Declaring PORT: string tells the compiler the value is present, but if the variable is unset at runtime it is still undefined. This pattern improves DX but provides no actual safety — prefer validation when correctness matters.

A validated env module with zod

The robust approach parses process.env once through a schema, failing fast with a clear error if anything is missing or malformed, and exports a fully typed, already-converted config object. zod makes the schema and the resulting type a single source of truth.

// src/env.ts
import { z } from "zod";

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(16),
});

const parsed = EnvSchema.safeParse(process.env);

if (!parsed.success) {
  console.error("Invalid environment variables:", z.treeifyError(parsed.error));
  process.exit(1);
}

export const env = parsed.data;

z.coerce.number() converts the string "3000" into a real number, and .url() / .min() reject invalid values at startup rather than mid-request. The exported env is typed from the schema, so env.PORT is number and env.NODE_ENV is the literal union.

Consuming the typed config

Import the validated env everywhere instead of touching process.env directly. The values are already the right types, so no casting or Number() calls clutter the code.

import { env } from "./env.js";

app.listen(env.PORT, () => {
  // env.PORT: number, env.NODE_ENV: "development" | "production" | "test"
  console.log(`[${env.NODE_ENV}] listening on :${env.PORT}`);
});

Deriving the type from the schema keeps types and validation in lockstep — add a field to the schema and it instantly appears (typed) on env, with no separate interface to maintain.

ApproachCompile-time autocompleteRuntime guaranteeType coercion
Raw process.envNoNoManual
ProcessEnv augmentationYesNoManual
Validated module (zod)YesYesAutomatic

Best Practices

  • Prefer a validated env module over ProcessEnv augmentation when correctness matters.
  • Parse process.env once at startup and fail fast on invalid configuration.
  • Use z.coerce (or equivalent) to turn string env values into numbers and booleans.
  • Derive the config type from the schema so types and validation never drift apart.
  • Import the typed env object instead of reading process.env throughout the app.
  • If you only want autocomplete, augment NodeJS.ProcessEnv, but never treat it as a guarantee.
  • Never commit secrets; load them from the environment or a secrets manager.
Last updated June 29, 2026
Was this helpful?