Skip to main content
Advertisement

13.5 Environment Variable Type Safety — Zod + process.env

Problems with Environment Variables

// ❌ No types — dangerous!
const dbUrl = process.env.DATABASE_URL // string | undefined
const port = process.env.PORT // string | undefined

// Errors discovered only after app starts
new Client({ connectionString: dbUrl }) // Runtime undefined error possible

Runtime Validation with Zod

npm install zod
// src/config/env.ts
import { z } from 'zod'

const envSchema = z.object({
// Server config
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
HOST: z.string().default('0.0.0.0'),

// Database
DATABASE_URL: z.string().url(),
DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(100).default(10),

// Authentication
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
JWT_EXPIRES_IN: z.string().default('7d'),
REFRESH_TOKEN_SECRET: z.string().min(32),

// External services
REDIS_URL: z.string().url().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().optional(),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),

// AWS
AWS_REGION: z.string().optional(),
AWS_ACCESS_KEY_ID: z.string().optional(),
AWS_SECRET_ACCESS_KEY: z.string().optional(),
S3_BUCKET: z.string().optional(),

// Monitoring
SENTRY_DSN: z.string().url().optional(),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
})

// Extract type
export type Env = z.infer<typeof envSchema>

// Run validation (at app startup)
function parseEnv(): Env {
const result = envSchema.safeParse(process.env)

if (!result.success) {
console.error('\n❌ Environment variable configuration errors:\n')
result.error.issues.forEach(issue => {
console.error(` [${issue.path.join('.')}] ${issue.message}`)
})
console.error('\nPlease check your .env file.\n')
process.exit(1)
}

return result.data
}

export const env = parseEnv()

Config File Pattern

// src/config/index.ts
import { env } from './env'

export const config = {
server: {
port: env.PORT,
host: env.HOST,
isDevelopment: env.NODE_ENV === 'development',
isProduction: env.NODE_ENV === 'production',
isTest: env.NODE_ENV === 'test',
},

database: {
url: env.DATABASE_URL,
poolSize: env.DATABASE_POOL_SIZE,
},

auth: {
jwtSecret: env.JWT_SECRET,
jwtExpiresIn: env.JWT_EXPIRES_IN,
refreshTokenSecret: env.REFRESH_TOKEN_SECRET,
},

mail: env.SMTP_HOST
? {
host: env.SMTP_HOST,
port: env.SMTP_PORT ?? 587,
user: env.SMTP_USER,
pass: env.SMTP_PASS,
}
: null,

aws: env.AWS_REGION
? {
region: env.AWS_REGION,
accessKeyId: env.AWS_ACCESS_KEY_ID!,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY!,
s3Bucket: env.S3_BUCKET,
}
: null,
}

export type Config = typeof config

dotenv Integration

npm install dotenv
// src/index.ts (at the very top of entry point)
import 'dotenv/config' // or
import dotenv from 'dotenv'
dotenv.config({ path: '.env.local' }) // Custom filename

import { env } from './config/env' // Validate after loading

// Now .env file is loaded into process.env

Environment-Specific .env File Strategy

.env                  # Common defaults (commit to git)
.env.development # Development environment (commit to git, exclude secrets)
.env.production # Production environment (commit to git, actual values injected via CI/CD)
.env.local # Local overrides (exclude from git)
.env.test # Test environment
# .env.example (commit to git — share required variable list)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
JWT_SECRET=your-jwt-secret-min-32-chars-here
REFRESH_TOKEN_SECRET=your-refresh-secret-min-32-chars

Test Environment Setup

// tests/setup.ts
process.env.NODE_ENV = 'test'
process.env.DATABASE_URL = 'postgresql://user:password@localhost:5432/testdb'
process.env.JWT_SECRET = 'test-jwt-secret-min-32-chars-long!'
process.env.REFRESH_TOKEN_SECRET = 'test-refresh-secret-min-32-chars!'

Pro Tips

1. Parallel type declaration (IDE support)

// src/types/env.d.ts
declare namespace NodeJS {
interface ProcessEnv extends z.infer<typeof envSchema> {}
}

2. Handle conditional config types safely

// Type-safe handling of optional service configuration
function getMailConfig() {
if (!env.SMTP_HOST) return null

// Once we get here, SMTP_HOST is confirmed to exist
return {
host: env.SMTP_HOST, // string (not undefined)
port: env.SMTP_PORT ?? 587,
}
}

const mailConfig = getMailConfig()
if (mailConfig) {
// mailConfig.host: string (type-safe)
}
Advertisement