How to Type Environment Variables in TypeScript Safely
Stop accessing process.env directly in TypeScript. Learn how to validate, type, and centralize environment variables so missing config fails fast at startup.
Unvalidated environment variables are a silent risk in every Node.js application. The DATABASE_URL your code expects might be missing in a staging environment. The PORT might be set to a string that breaks your integer parsing. The API_KEY might be an empty string that slips through as truthy.
TypeScript's type for process.env values is string | undefined. Many developers cast that away with ! or ignore the undefined entirely. This guide shows how to handle environment variables correctly — validated at startup, typed throughout the application, with clear errors when something is missing.
The Problem with Direct process.env Access
Consider a typical Node.js pattern:
const db = new Database({
url: process.env.DATABASE_URL,
poolSize: parseInt(process.env.DB_POOL_SIZE),
});TypeScript accepts this but it is broken in several ways. process.env.DATABASE_URL is string | undefined — and Database probably expects string. parseInt(process.env.DB_POOL_SIZE) returns NaN when the variable is missing. Neither failure is obvious at startup. Both surface as cryptic errors at runtime, often mid-request.
The fix is to validate environment variables once, at startup, and fail immediately with a clear message if anything is wrong.
Typing Environment Variables in TypeScript: The Manual Approach
The simplest correct pattern is a validation function and a single config object:
function requireEnv(key: string): string {
const value = process.env[key];
if (value === undefined || value === "") {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
function requireEnvInt(key: string): number {
const raw = requireEnv(key);
const parsed = parseInt(raw, 10);
if (isNaN(parsed)) {
throw new Error(`Environment variable ${key} must be an integer, got: "${raw}"`);
}
return parsed;
}
export const config = {
database: {
url: requireEnv("DATABASE_URL"),
poolSize: requireEnvInt("DB_POOL_SIZE"),
},
server: {
port: requireEnvInt("PORT"),
host: requireEnv("HOST"),
},
api: {
key: requireEnv("API_KEY"),
secret: requireEnv("API_SECRET"),
},
} as const;This config object is constructed once at module load time. If any required variable is missing or malformed, the process throws immediately with a specific message naming the missing key. No mystery crashes later.
The type of config is inferred precisely — config.server.port is number, not string | undefined. Every consumer of config in the application has accurate types without any casting.
Using Zod for Environment Validation
For applications with many configuration values, or where validation rules are more complex, a schema library like Zod gives you both validation and type inference together:
import { z } from "zod";
const EnvSchema = z.object({
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
DB_POOL_SIZE: z.coerce.number().int().positive().default(10),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
HOST: z.string().default("0.0.0.0"),
NODE_ENV: z.enum(["development", "staging", "production"]).default("development"),
API_KEY: z.string().min(1, "API_KEY cannot be empty"),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid environment configuration:");
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;z.coerce.number() converts the string "3000" from process.env.PORT to the number 3000. .default(3000) provides a fallback when the variable is not set. safeParse returns a result object rather than throwing, which lets you collect and log all validation errors at once before exiting.
The type of env is inferred from the schema:
// TypeScript infers:
// env.DATABASE_URL: string
// env.DB_POOL_SIZE: number
// env.PORT: number
// env.NODE_ENV: "development" | "staging" | "production"
// etc.Every consumer of env in the application has accurate, narrow types.
Centralizing Config: One Module, One Import
Whether you use the manual approach or Zod, the key discipline is centralizing all environment access in a single module. Application code should never call process.env directly — it should import from config or env.
// Good — imports from the validated config module
import { config } from "./config";
const db = new Database({ url: config.database.url });
// Bad — direct process.env access scattered throughout the codebase
const db = new Database({ url: process.env.DATABASE_URL! });Centralizing serves several purposes. First, it is the single place where validation and transformation happen. Second, it is easy to audit — one file shows every external dependency. Third, in tests you can mock the config module rather than setting environment variables.
Handling Optional vs Required Variables
Not every environment variable is required. Be explicit about which ones are optional:
const env = {
// Required — will throw if missing
databaseUrl: requireEnv("DATABASE_URL"),
// Optional with a default
port: parseInt(process.env.PORT ?? "3000", 10),
// Optional, genuinely optional — callers must handle undefined
sentryDsn: process.env.SENTRY_DSN,
};The type of env.sentryDsn is string | undefined, which forces callers to handle the absent case. If SENTRY_DSN is missing, Sentry is simply not initialized — not a crash.
Getting configuration right is foundational work that pays dividends every deployment. If your application is scaling and you want a reliable, typed configuration layer, Clixo can help you build it correctly.