Skip to main content
Advertisement

18.1 Type-Safe Environment Variable Management — Zod-Based Config Module

The Problem with Environment Variables

// Problem: process.env values are string | undefined
const port = process.env.PORT // string | undefined
const timeout = process.env.TIMEOUT // string | undefined

// Errors only discovered at runtime
app.listen(port) // Can become NaN!

Zod-Based Environment Variable Validation

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

const envSchema = z.object({
// Server configuration
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),

// Database
DATABASE_URL: z.string().url('Please provide a valid database URL.'),
DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(100).default(10),

// JWT authentication
JWT_SECRET: z.string().min(32, 'JWT secret must be at least 32 characters.'),
JWT_EXPIRES_IN: z.string().default('1h'),
REFRESH_TOKEN_SECRET: z.string().min(32),
REFRESH_TOKEN_EXPIRES_IN: z.string().default('7d'),

// External services (optional)
REDIS_URL: z.string().url().optional(),
AWS_REGION: z.string().optional(),
AWS_S3_BUCKET: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().int().optional(),

// Logging
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
})

// Type inference
type Env = z.infer<typeof envSchema>

// Validate on app startup — exits immediately on failure
function validateEnv(): Env {
const result = envSchema.safeParse(process.env)

if (!result.success) {
console.error('❌ Environment variable validation failed:')
result.error.issues.forEach(issue => {
console.error(` ${issue.path.join('.')}: ${issue.message}`)
})
process.exit(1)
}

return result.data
}

export const env = validateEnv()

Domain-Separated Configuration

// config/database.config.ts
import { env } from './env'

export const databaseConfig = {
url: env.DATABASE_URL,
pool: {
max: env.DATABASE_POOL_SIZE,
min: 2,
idleTimeoutMillis: 30_000,
},
ssl: env.NODE_ENV === 'production' ? { rejectUnauthorized: true } : false,
} as const

// config/auth.config.ts
export const authConfig = {
jwt: {
secret: env.JWT_SECRET,
expiresIn: env.JWT_EXPIRES_IN,
},
refreshToken: {
secret: env.REFRESH_TOKEN_SECRET,
expiresIn: env.REFRESH_TOKEN_EXPIRES_IN,
},
} as const

// config/app.config.ts
export const appConfig = {
port: env.PORT,
nodeEnv: env.NODE_ENV,
isDevelopment: env.NODE_ENV === 'development',
isProduction: env.NODE_ENV === 'production',
isTest: env.NODE_ENV === 'test',
logLevel: env.LOG_LEVEL,
} as const

// Unified config export
export { env, databaseConfig, authConfig, appConfig }

.env File Strategy

# .env.example (committed to git — example values)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
JWT_SECRET=your-super-secret-jwt-key-at-least-32-characters
REFRESH_TOKEN_SECRET=your-refresh-token-secret-at-least-32-chars

# .env (excluded from git — real values)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://admin:realpass@localhost:5432/myapp_dev
JWT_SECRET=a-real-secret-key-that-is-long-enough-32chars

# .env.test (test environment)
NODE_ENV=test
DATABASE_URL=postgresql://admin:realpass@localhost:5432/myapp_test

# .env.production (inject as system env vars at deploy time)
# Don't use .env files — set system environment variables directly

Next.js Environment Variable Type Safety

// Using @t3-oss/env-nextjs
// npm install @t3-oss/env-nextjs zod
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

export const env = createEnv({
// Server-only (inaccessible from client)
server: {
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
NEXTAUTH_SECRET: z.string().min(32),
NEXTAUTH_URL: z.string().url(),
},

// Client-public (requires NEXT_PUBLIC_ prefix)
client: {
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_GA_ID: z.string().optional(),
},

// Bind to actual environment variables
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
JWT_SECRET: process.env.JWT_SECRET,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_GA_ID: process.env.NEXT_PUBLIC_GA_ID,
},
})

// Usage
import { env } from '@/config/env'
const apiUrl = env.NEXT_PUBLIC_API_URL // string (validated)

Pro Tips

Override Environment Variables in Tests

// test/setup.ts
import { vi } from 'vitest'

// Mock environment variables
vi.stubEnv('DATABASE_URL', 'postgresql://test:test@localhost:5432/testdb')
vi.stubEnv('JWT_SECRET', 'test-secret-that-is-long-enough-for-testing')

// Or set directly in setupFiles
process.env.NODE_ENV = 'test'
process.env.DATABASE_URL = 'postgresql://...'
Advertisement