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"to3000for 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
| Situation | Recommended Approach |
|---|---|
| Node.js / Bun backend | zod + centralized env.ts |
| Next.js app | @t3-oss/env-nextjs |
| Simple project, no Zod | requireEnv() helper functions |
| Type safety only (no runtime) | ProcessEnv declaration augmentation |
| All of the above | Runtime 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!