Creating Type-Safe Environment Variables in TypeScript

· 2 min read
typescript zod configuration

What We’re Building

A typed environment configuration that validates at startup, provides autocomplete, and fails fast with clear error messages if required variables are missing.

Prerequisites

  • TypeScript project
  • Basic Zod knowledge (or willingness to learn)

The Approach

  1. Define a schema for environment variables
  2. Validate at application startup
  3. Export typed configuration object
  4. Handle errors gracefully

Step 1: Install Zod

npm install zod

Step 2: Define the Schema

Create src/env.ts:

import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
  ENABLE_FEATURE_X: z.coerce.boolean().default(false),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});

type Env = z.infer<typeof envSchema>;

Key points:

  • z.coerce.number() converts string “3000” to number 3000
  • z.coerce.boolean() handles “true”/“false” strings
  • .default() provides fallbacks for optional variables

Step 3: Validate and Export

function validateEnv(): Env {
  const parsed = envSchema.safeParse(process.env);

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

  return parsed.data;
}

export const env = validateEnv();

Step 4: Use Throughout Your App

import { env } from './env';

// Full autocomplete and type safety
const server = app.listen(env.PORT, () => {
  console.log(`Running in ${env.NODE_ENV} mode on port ${env.PORT}`);
});

if (env.ENABLE_FEATURE_X) {
  // TypeScript knows this is boolean, not string
}

Step 5: Add Custom Transformations

const envSchema = z.object({
  // Parse comma-separated list
  ALLOWED_ORIGINS: z
    .string()
    .transform((s) => s.split(',').map((s) => s.trim()))
    .default(''),

  // Parse JSON
  FEATURE_FLAGS: z
    .string()
    .transform((s) => JSON.parse(s))
    .pipe(z.record(z.boolean()))
    .default('{}'),
});

The Result

  • Immediate startup failure with clear errors if config is wrong
  • Full TypeScript autocomplete for env.VARIABLE_NAME
  • No more process.env.THING as string casts
  • Coercion handles string→number/boolean automatically

What I’d Do Differently

Add this validation to your build process too. Catch missing variables in CI, not production.


This pattern has saved me from countless “undefined is not a valid port” errors in production.

Related Posts

Comments