Type-Safe Environment Variables in Node.js 22 with TypeScript 5.x

Type-Safe Environment Variables in Node.js 22 with TypeScript 5.x

Parse and validate process.env at startup with TypeScript 5.x so missing or malformed configuration fails fast instead of at runtime in production.

process.env in Node.js is a flat map of strings. TypeScript types it as NodeJS.ProcessEnv, where every value is string | undefined. That is accurate at the type level and dangerous at runtime: a missing DATABASE_URL or a mistyped PORT often surfaces only when a request fails deep inside application code.

Node.js 22 continues to improve the runtime (native TypeScript type stripping in some workflows, stable fetch, refined test runner), but it does not magically validate your environment. TypeScript 5.x gives you stricter inference, satisfies, and improved module resolution—but still no built-in env schema.

This guide walks through a practical pattern: parse once at startup, produce a typed config object, and fail fast with clear errors before the server accepts traffic.

Why validate at startup

Configuration errors are among the cheapest failures to fix and the most expensive to discover late.

| Failure mode | Without validation | With startup validation |
|--------------|-------------------|-------------------------|
| Missing API key | 500 on first external call | Process exits with message |
| PORT=abc | NaN bind or silent fallback | Clear parse error |
| DEBUG=1 vs true | Inconsistent truthiness | Coerced boolean |
| Wrong URL scheme | Error inside HTTP client | URL format rejected at boot |

Failing at startup improves deploy feedback: CI, staging, and orchestration health checks catch misconfiguration before users do.

The shape of a type-safe env module

A typical layout:

``
src/
config/
env.ts # schema + parse + export
index.ts # imports config first
``

Rules:

  1. Only env.ts reads process.env directly (or a thin wrapper you control)
  2. Export a frozen config object with concrete types
  3. Call loadConfig() before creating servers, database pools, or job workers
  4. Never spread process.env into application code

Approach 1: Zod schema

Zod is widely used for runtime validation with inferred TypeScript types.

```typescript
import { z } from "zod";

const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_URL: z.string().url(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
FEATURE_BETA: z
.enum(["true", "false"])
.default("false")
.transform((v) => v === "true"),
OPTIONAL_WEBHOOK: z.string().url().optional(),
});

export type AppConfig = z.infer<typeof envSchema>;

export function loadConfig(): AppConfig {
const result = envSchema.safeParse(process.env);
if (!result.success) {
const formatted = result.error.flatten().fieldErrors;
console.error("Invalid environment configuration:", formatted);
process.exit(1);
}
return Object.freeze(result.data);
}

export const config = loadConfig();
```

Notes on this pattern

  • z.coerce.number() handles string env values ("3000"3000)
  • Enums restrict known values; typos fail validation
  • transform maps string flags to booleans explicitly—avoid implicit if (process.env.X) truthiness
  • Object.freeze discourages accidental mutation

Import config from a single module:

```typescript
import { config } from "./config/env.js";

const port = config.PORT;
```

With TypeScript 5.x moduleResolution set to node16 or bundler, use .js extensions in import paths when emitting ESM.

Approach 2: envalid

envalid is purpose-built for environment variables.

```typescript
import { cleanEnv, str, port, url, bool } from "envalid";

export const config = cleanEnv(process.env, {
NODE_ENV: str({ choices: ["development", "test", "production"], default: "development" }),
PORT: port({ default: 3000 }),
DATABASE_URL: url(),
LOG_LEVEL: str({ choices: ["debug", "info", "warn", "error"], default: "info" }),
FEATURE_BETA: bool({ default: false }),
});
```

cleanEnv throws on invalid input with readable messages. Types are attached to config.PORT, etc.

Choose Zod if you already use it for API validation; choose envalid for a minimal env-specific API.

TypeScript typing without a runtime library

You can narrow types manually for very small projects:

``typescript
function requireEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value === "") {
throw new Error(
Missing required environment variable: ${name}`);
}
return value;
}

export const config = {
DATABASE_URL: requireEnv("DATABASE_URL"),
PORT: Number(requireEnv("PORT")),
} as const;
```

Downsides: no format validation (URLs, enums), easy to forget coercion, harder to test. Acceptable for scripts; prefer schema validation for services.

ESM, import.meta, and load order

In ESM, top-level import runs before module body execution. If config/env.ts calls loadConfig() at top level, any module that imports config triggers validation early—which is usually what you want.

Avoid circular imports: env.ts should not import application modules that themselves import env.ts.

For tests, expose a factory:

``typescript
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
// parse env argument
}
``

Tests pass fake env objects without mutating global process.env.

Node.js 22 specifics

Native .env loading

Node.js 20.6+ supports --env-file=.env to load dotenv-style files without a dependency. Variables still arrive as strings in process.env; validation requirements are unchanged.

``bash
node --env-file=.env dist/index.js
``

Document required variables in .env.example (committed) vs .env (not committed).

NODE_OPTIONS and test runs

When using node --test, ensure test scripts set required env vars or mock loadConfig. Vitest and Node's test runner can set process.env in setup files before importing modules under test.

TypeScript execution

Node 22 can run TypeScript directly with type stripping in supported configurations, but production builds typically still compile with tsc or a bundler. Keep env.ts in the compiled output; do not assume types exist at runtime.

Grouping configuration by concern

Large apps split schemas:

```typescript
const dbSchema = z.object({ DATABASE_URL: z.string().url() });
const authSchema = z.object({ JWT_SECRET: z.string().min(32) });
const serverSchema = z.object({ PORT: z.coerce.number().default(3000) });

const envSchema = dbSchema.merge(authSchema).merge(serverSchema);
```

Alternatively, nested config:

``typescript
export const config = {
server: { port: parsed.PORT },
db: { url: parsed.DATABASE_URL },
};
``

Nested objects improve discoverability in editors without changing validation rules.

Secrets vs non-secrets

Treat secrets as opaque strings—validate presence and minimum length, not content structure in logs.

  • Never log parsed config objects containing secrets
  • Redact in error messages if you custom-format Zod errors
  • In Kubernetes and Cloudflare Workers, secrets often inject at runtime; local .env should not contain production secrets

For Workers, use wrangler secret and validate env bindings in the Worker entry with the same schema patterns adapted to the Env interface.

Optional and feature-flag variables

Use .optional() or defaults deliberately:

``typescript
ANALYTICS_KEY: z.string().optional(),
``

Document behavior when optional keys are absent: disable feature vs use no-op adapter. Avoid process.env.ANALYTICS_KEY && ... scattered across the codebase—centralize in config.

CI and deployment checks

Add a CI step that validates env against schema using a fixture file:

``bash
node --env-file=.env.example -e "import('./dist/config/env.js')"
``

Or a small script that loads schema with .env.example values (non-secret placeholders) to ensure the example stays in sync with required keys.

Container orchestrators: fail the deploy if health check never passes because config exit code was non-zero.

Common pitfalls

Validating too late: Lazy-reading process.env in database module constructor bypasses startup failure.

Stringly booleans: if (process.env.ENABLED) is true for "false". Parse explicitly.

Implicit defaults in code: Default port in three places causes drift. One schema default.

Different schemas per deploy target: Staging may omit optional integrations; use discriminatedUnion on DEPLOY_TARGET or separate schema files.

Over-validation: Rejecting valid but uncommon URL formats blocks deploys. Match validation strictness to actual requirements.

Workers, Docker, and twelve-factor alignment

The same parse-once pattern applies outside a long-running Node server.

Docker and Compose

Pass env via Compose environment or env_file. Validate in the container entrypoint before starting Node:

``dockerfile
CMD ["node", "dist/index.js"]
``

If loadConfig() runs at import time, a bad Compose file fails the container immediately—docker compose ps shows exit code 1, which is easier to debug than a hung health check.

Cloudflare Workers

Workers receive bindings on the env parameter, not process.env. Define a Zod schema against a plain object built from env:

``typescript
export function parseWorkerEnv(env: Env): WorkerConfig {
return workerEnvSchema.parse({
CANONICAL_ORIGIN: env.CANONICAL_ORIGIN,
DB: env.DB, // binding presence, not a string URL
});
}
``

Separate secrets (set via dashboard or wrangler secret) from vars in wrangler.jsonc. Document both in your schema.

Twelve-factor config

Store config in the environment, not in code. Type-safe validation implements the "config" factor correctly: one interface, many deploy targets, same validation rules. Avoid checking process.env.NODE_ENV === 'production' in dozens of modules—encode environment-specific defaults in the schema with NODE_ENV as a discriminant when truly necessary.

Testing validation logic

Unit test the schema without starting a server:

```typescript
import { describe, it, expect } from "node:test";
import { loadConfig } from "./env.js";

describe("loadConfig", () => {
it("rejects missing DATABASE_URL", () => {
expect(() =>
loadConfig({ NODE_ENV: "test", PORT: "3000" })
).toThrow();
});

it("coerces PORT", () => {
const cfg = loadConfig({
NODE_ENV: "test",
PORT: "8080",
DATABASE_URL: "https://example.com",
LOG_LEVEL: "info",
});
expect(cfg.PORT).toBe(8080);
});
});
```

Refactoring env names becomes a schema change with failing tests—not a production outage.

Limitations

Runtime validation does not prevent env changes after startup in long-lived processes without restart. Orchestration platforms that rotate secrets may require reload hooks—outside the scope of one-shot parse.

Schema libraries add a dependency and small startup cost—negligible for most services.

TypeScript types from inference do not protect against someone casting around them (as any). Enforce importing only from config.

Summary

Type-safe environment variables in Node.js 22 with TypeScript 5.x mean: define a schema, parse process.env once at startup, export a frozen typed config, and exit on failure. Use Zod or envalid for coercion and messages; keep secrets out of logs; align .env.example with CI checks. The goal is not clever types—it is failing in the deploy log instead of in production traffic.