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:
- Managing dozens of environment variables in large codebases gets chaotic fast.
- I’m lazy and don’t wanna write
process.env.all over my code base when I can just useENV. - 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??
- 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.
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 envA 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=errorTo 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:
:quality(80))