Best Ways to Handle Environment Variables in TypeScript

Learn how to safely load, validate, and type environment variables in TypeScript using Zod, dotenv, and a centralized config module — so you get compile-time safety and runtime validation instead of silent undefined errors.

Hero image for Best Ways to Handle Environment Variables in TypeScript

Environment variables are how you configure applications without hardcoding secrets. But in TypeScript, process.env.SOME_VAR always has type string | undefined — and that’s a problem. Missing a variable at runtime causes crashes that are hard to diagnose. Mistyping a variable name causes silent undefined bugs.

The solution is a centralized, validated environment config module. Here’s how to build one properly.

The Problem with Raw process.env

// ❌ The TypeScript problem: everything is string | undefined
const port = process.env.PORT;
//    ^? string | undefined

// This will throw at runtime if PORT is undefined
const server = app.listen(port);

// This silently passes NaN to listen()
const server = app.listen(Number(process.env.PORT));

// Even worse — typos give no error
const apiKey = process.env.API_KEYYY; // undefined, no TypeScript error

TypeScript can’t know what environment variables exist at compile time, so it gives everything the type string | undefined. You end up either fighting with undefined checks everywhere or ignoring them with ! assertions.

Solution 1: Centralized Config Module with Zod

Zod is a TypeScript-first schema validation library. Using it to parse process.env gives you:

  • Runtime validation — fails loudly at startup if anything is missing or wrong
  • Type inference — the validated object has precise TypeScript types
  • Coercion — automatically convert "3000" to 3000 for number fields

Install

npm install zod dotenv
# or
bun add zod dotenv

Create src/env.ts

import { z } from "zod";
import "dotenv/config";

const envSchema = z.object({
  // Node environment
  NODE_ENV: z
    .enum(["development", "test", "production"])
    .default("development"),

  // Server
  PORT: z.coerce.number().int().positive().default(3000),
  HOST: z.string().default("localhost"),

  // Database
  DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),

  // Auth
  JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
  JWT_EXPIRES_IN: z.string().default("7d"),

  // External APIs
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),

  // Optional
  SENTRY_DSN: z.string().url().optional(),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});

// Parse and validate — throws at startup if invalid
const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error("❌ Invalid environment variables:");
  console.error(parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const env = parsed.data;

// Export the type for use elsewhere
export type Env = typeof env;

Use It

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

// env.PORT is typed as number (not string | undefined)
const server = app.listen(env.PORT, env.HOST, () => {
  console.log(`Server running on http://${env.HOST}:${env.PORT}`);
});

// env.DATABASE_URL is typed as string (validated URL)
const db = new Pool({ connectionString: env.DATABASE_URL });

// env.NODE_ENV is typed as 'development' | 'test' | 'production'
if (env.NODE_ENV === "production") {
  // TypeScript knows this branch is valid
}

No more string | undefined. No more silent undefined bugs. The app fails at startup with a clear error if anything is wrong.

Solution 2: t3-env for Framework-Specific Validation

If you’re using Next.js, the @t3-oss/env-nextjs package handles the client/server split automatically:

npm install @t3-oss/env-nextjs zod
// src/env.ts
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

export const env = createEnv({
  // Server-side variables (never exposed to the browser)
  server: {
    DATABASE_URL: z.string().url(),
    JWT_SECRET: z.string().min(32),
    STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  },

  // Client-side variables (must be prefixed with NEXT_PUBLIC_)
  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
    NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith("pk_"),
  },

  // How to get the runtime values
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    JWT_SECRET: process.env.JWT_SECRET,
    STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
    NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:
      process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
  },
});

t3-env also prevents accidentally accessing server-only variables on the client.

Solution 3: Simple Typed Config Without Zod

If you want to avoid the Zod dependency, you can write a simple helper:

// src/config.ts
import "dotenv/config";

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

function optionalEnv(name: string, defaultValue: string): string {
  return process.env[name] ?? defaultValue;
}

function requireEnvAsNumber(name: string): number {
  const value = requireEnv(name);
  const num = Number(value);
  if (isNaN(num)) {
    throw new Error(
      `Environment variable ${name} must be a number, got: "${value}"`,
    );
  }
  return num;
}

function requireEnvAsBoolean(name: string): boolean {
  const value = requireEnv(name).toLowerCase();
  if (value !== "true" && value !== "false") {
    throw new Error(
      `Environment variable ${name} must be 'true' or 'false', got: "${value}"`,
    );
  }
  return value === "true";
}

export const config = {
  nodeEnv: optionalEnv("NODE_ENV", "development") as
    | "development"
    | "test"
    | "production",
  port: requireEnvAsNumber("PORT"),
  databaseUrl: requireEnv("DATABASE_URL"),
  jwtSecret: requireEnv("JWT_SECRET"),
  debugMode: requireEnvAsBoolean("DEBUG_MODE"),
} as const;

This is lightweight but gives you typed access and clear errors at startup.

Structuring Your .env Files

Keep separate .env files for different environments:

.env                 # Defaults (safe to commit — no secrets)
.env.local           # Local overrides (gitignored)
.env.development     # Dev-specific (gitignored or committed if no secrets)
.env.test            # Test-specific
.env.production      # Production values (NEVER commit this)
# .env (safe to commit)
NODE_ENV=development
PORT=3000
HOST=localhost
LOG_LEVEL=debug

# .env.local (gitignored — your local secrets)
DATABASE_URL=postgresql://localhost:5432/mydb
JWT_SECRET=local-dev-secret-at-least-32-characters-long
STRIPE_SECRET_KEY=sk_test_your_local_key

Always add secret files to .gitignore:

.env.local
.env.*.local
.env.production
.env.staging

Provide a .env.example File

Commit a .env.example (or .env.template) with all required keys but no values:

# .env.example — commit this file
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
JWT_SECRET=your-secret-here-must-be-32-chars-minimum
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
SENTRY_DSN=  # optional

New developers can copy this file and fill in their own values:

cp .env.example .env.local

Type-Safe Access to process.env (Without a Schema)

If you can’t validate at runtime (e.g., in a browser build), you can at least add TypeScript declarations to get autocomplete and prevent typos:

// src/env.d.ts — augment NodeJS ProcessEnv
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      readonly NODE_ENV: "development" | "test" | "production";
      readonly PORT: string;
      readonly DATABASE_URL: string;
      readonly JWT_SECRET: string;
      readonly STRIPE_SECRET_KEY: string;
      // Optional
      readonly SENTRY_DSN?: string;
    }
  }
}

export {};

Now process.env.DATABASE_URL is typed as string (not string | undefined) and process.env.STRIPE_SECERT_KEY (typo) gives a TypeScript error.

Note: This is purely a compile-time improvement — it doesn’t validate at runtime. Combine with runtime validation for full coverage.

Validating at Build Time with CI

Add a validation step to your CI pipeline that ensures all required variables are present:

# check-env.sh
#!/bin/bash
required_vars=(
  "DATABASE_URL"
  "JWT_SECRET"
  "STRIPE_SECRET_KEY"
)

missing=()
for var in "${required_vars[@]}"; do
  if [[ -z "${!var}" ]]; then
    missing+=("$var")
  fi
done

if [[ ${#missing[@]} -gt 0 ]]; then
  echo "❌ Missing required environment variables:"
  printf '  - %s\n' "${missing[@]}"
  exit 1
fi

echo "✅ All required environment variables are set"

Or use a TypeScript script that imports your env.ts module — it will fail with the Zod error if anything is missing.

Summary: Which Approach to Use

SituationRecommended Approach
Node.js / Bun backendzod + centralized env.ts
Next.js app@t3-oss/env-nextjs
Simple project, no ZodrequireEnv() helper functions
Type safety only (no runtime)ProcessEnv declaration augmentation
All of the aboveRuntime validation + type declarations

Conclusion

Environment variables are the interface between your code and the world it runs in. Treating them with the same rigor as function arguments — validating them at startup and giving them proper types — eliminates an entire class of bugs.

The three-minute investment in setting up a validated env.ts module will save hours of debugging mysterious undefined errors in production.


💬 Want to learn, build, and grow with a community of developers? Join the King Technologies Discord — where code meets community!