tech-guide 4 min read

Failing fast: Building type safe environment variables and validation with Zod

Failing fast: Building type safe environment variables and validation with Zod

Jumping into various codebases as a contractor, I frequently notice a massive gap: the lack of a type-safe env.ts file. Implementing one is a low-effort, high-reward move, yet I rarely see it. 

What do I see? Runtime surprises. A deployment goes live, but the application crashes because an environment variable wasn’t set or a CI/CD pipeline variable was completely forgotten. Lol, yay.

Let’s catch those failures at startup by creating a type-safe environment variables file using [Zod](https://zod.dev/) and [dotenv](https://www.npmjs.com/package/dotenv). A few advantages to this approach: 

  1. Managing dozens of environment variables in large codebases gets chaotic fast. 
  2. I’m lazy and don’t wanna write process.env.  all over my code base when I can just use ENV. 
  3. We add immediate validation to ensure variables exist, match the correct data type, follow specific patterns, and more. Who doesn’t love detailed and specific error messages?? 
  4. We get full IDE autocomplete and type safety throughout the codebase.

Setting Up Your Type-Safe Schema

These two little libraries save an immense amount of headache. I use dotenv to control exactly which configuration file to load (which is also handy for your package.json scripts, but that’s a blog post for another day). Zod, in my opinion, is easily one of the best tools in the modern TypeScript ecosystem.

Note

I use Zod for creating almost all my data shapes. While it might seem like overkill to some, it gives you a type-first schema declaration for runtime validation, and then allows you to seamlessly infer those types for build time.

So I start with making a env.ts right in the project root. Below is a stripped down example that covers common validation patterns I work with.

// env.ts

import dotenv from 'dotenv'
import { z } from 'zod'

// APP stage controls which .env file is loaded.
// NODE_ENV should be reserved for runtime optimizations.
process.env.APP_STAGE ??= 'local'

switch (process.env.APP_STAGE) {
  case 'local':
    dotenv.config({ path: '.env.local' })
    break

  case 'dev':
    dotenv.config({ path: '.env.dev' })
    break

  case 'test':
    dotenv.config({ path: '.env.test' })
    break

  default:
    dotenv.config({ path: '.env' })
}

const envSchema = z.object({
  NODE_ENV: z
    .enum(['development', 'test', 'production'])
    .default('development'),
  APP_STAGE: z
    .enum(['local', 'dev', 'test', 'production'])
    .default('dev'),
  PORT: z.coerce.number().positive().default(3000),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  DATABASE_URL: z.string().startsWith('postgresql://'),
  JWT_SECRET: z.string().min(32, 'Must be at least 32 characters'),
  JWT_EXPIRES_IN: z.string().default('7d'),
  BCRYPT_ROUNDS: z.coerce.number().min(10).max(20).default(12),
})

let validatedEnv: z.infer<typeof envSchema>

try {
  validatedEnv = envSchema.parse(process.env)
} catch (error) {
  if (error instanceof z.ZodError) {
    console.error('❌ Invalid environment variables\n')
    error.issues.forEach((issue) => {
      console.error(`${issue.path.join('.')}: ${issue.message}`)
    })
  } else {
    console.error('❌ An unexpected error occurred during env validation:', error)
  }
  process.exit(1)
}

export const env = validatedEnv

export type Env = typeof env

export const isProd = () => env.APP_STAGE === 'production'
export const isDev = () => env.APP_STAGE === 'dev'
export const isTest = () => env.APP_STAGE === 'test'

export default env

A few opinionated choices

Why the custom APP_STAGE variable and not NODE_ENV. You see people using NODE_ENV to dictate application behavior everywhere, but NODE_ENV should be reserved entirely for runtime optimizations. For instance, setting NODE_ENV=production tells package managers like npm or Yarn to skip installing devDependencies, and tells libraries like React or Express to run in their fastest, compiled states. Using a separate APP_STAGE allows you to explicitly manage your configuration environments without interfering with those optimizations.

I am using Zod's parse() method over safeParse() because I want STRICT contract enforcement at startup. If the environment is misconfigured, the application should fail immediately. Wrapping parse in a try/catch block allows us to intercept the failure and format the output cleanly. If you prefer to avoid try/catch, safeParse is an excellent alternative that returns an object containing a success boolean and the data or error details.

Putting the validations to work

The env.local or whatever

# Database Configuration
DATABASE_URL=postgresql://username:password@localhost:5432/my_sick_app_db

# Server Configuration
PORT=3000
NODE_ENV=development
APP_STAGE=local

# JWT Configuration
JWT_SECRET=fa74311f8872d8cd57542937fe6e8ab735fa0ffa5d31b6d703439e229cfc6ed8
JWT_EXPIRES_IN=7d

# Security
BCRYPT_SALT_ROUNDS=12

# Logging
LOG_LEVEL=error

To enforce this globally, simply import your new env.ts file at the very top of your application entry point (e.g., index.ts or server.ts).

import { env } from '../env.ts'
import app from './server.ts'

// Start the server
app.listen(env.PORT, () => {
  console.log(`Server running on port ${env.PORT} in ${env.NODE_ENV} mode`)
})

The Error Output

Because we intercepted the validation using the try/catch block, we now have total control over how configuration errors display in the terminal. If you purposefully fudge the database URL format to something like DATABASE_URL=https://username:password@localhost:5432/my_sick_app_db (omitting the postgresql:// protocol), the schema catch block will intercept it before the app even attempts to listen on a port:

// 06.contact

Bring your vision into focus.

From initial market strategy to deep code implementation, together we design and build full-lifecycle digital ecosystems. Drop a brief, a deck, or an open problem, and let Branden Builds do his thing.

Start a project

Let's get to work.