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.
Missing environment variables and malformed numbers can cause configuration failures at runtime. Validate required values before the application begins accepting requests.
Node.js environment values are typed as string | undefined. A non-null assertion changes the compiler’s view of a value; it does not check that the value exists or has the required format.
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),
});With strict null checks, the compiler can flag these possibly undefined arguments. If those checks are bypassed, a missing pool size can become NaN, and the database URL may be absent. Types alone cannot validate values supplied by the deployment environment.
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
A validation function and a shared config object make the checks explicit:
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 object is constructed at module load time. The example rejects missing values and numbers that cannot be parsed, but parseInt accepts numeric prefixes such as 12abc. Add full-string, range, and format validation for each setting; the schema below shows more specific constraints.
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,
};With strict null checks, the type of env.sentryDsn is string | undefined. Callers must decide whether to initialize Sentry when it is absent. The optional port example still needs numeric validation if the environment supplies a value.