A TypeScript utility that validates environment variables against a Zod schema at application startup, providing type-safe access to env vars and clear error messages when validation fails.
import { z, ZodError, ZodType } from 'zod';
type EnvSchema = Record<string, ZodType<unknown>>;
interface CreateEnvOptions<T extends EnvSchema> {
schema: z.ZodObject<T>;
env?: Record<string, string | undefined>;
onValidationError?: (error: EnvValidationError) => never;
}
class EnvValidationError extends Error {
public readonly errors: Array<{
variable: string;
message: string;
received?: unknown;
}>;
constructor(
errors: Array<{ variable: string; message: string; received?: unknown }>
) {
const formattedErrors = errors
.map((e) => {
const receivedInfo = e.received !== undefined ? ` (received: ${JSON.stringify(e.received)})` : '';
return ` ✗ ${e.variable}: ${e.message}${receivedInfo}`;
})
.join('\n');
super(
`\n❌ Environment validation failed:\n\n${formattedErrors}\n\n` +
`Please check your .env file or environment configuration.\n`
);
this.name = 'EnvValidationError';
this.errors = errors;
}
}
function formatZodError(error: ZodError): Array<{
variable: string;
message: string;
received?: unknown;
}> {
return error.errors.map((issue) => {
const variable = issue.path.join('.');
let message: string;
let received: unknown;
switch (issue.code) {
case 'invalid_type':
if (issue.received === 'undefined') {
message = 'Required variable is missing';
} else {
message = `Expected ${issue.expected}, received ${issue.received}`;
received = issue.received;
}
break;
case 'invalid_enum_value':
message = `Must be one of: ${issue.options.join(', ')}`;
received = issue.received;
break;
case 'too_small':
message = `String must be at least ${issue.minimum} character(s)`;
break;
case 'invalid_string':
message = `Invalid format: expected ${issue.validation}`;
break;
default:
message = issue.message;
}
return { variable, message, received };
});
}
export function createEnv<T extends EnvSchema>(
options: CreateEnvOptions<T>
): z.infer<z.ZodObject<T>> {
const { schema, env = process.env, onValidationError } = options;
const result = schema.safeParse(env);
if (!result.success) {
const formattedErrors = formatZodError(result.error);
const validationError = new EnvValidationError(formattedErrors);
if (onValidationError) {
onValidationError(validationError);
}
throw validationError;
}
return Object.freeze(result.data) as z.infer<z.ZodObject<T>>;
}
// Helper for common env var transformations
export const envHelpers = {
port: (defaultPort = 3000) =>
z
.string()
.default(String(defaultPort))
.transform((val) => parseInt(val, 10))
.refine((val) => val >= 1 && val <= 65535, {
message: 'Port must be between 1 and 65535',
}),
boolean: (defaultValue = false) =>
z
.enum(['true', 'false', '1', '0', ''])
.default(defaultValue ? 'true' : 'false')
.transform((val) => val === 'true' || val === '1'),
url: () =>
z.string().url({ message: 'Must be a valid URL' }),
email: () =>
z.string().email({ message: 'Must be a valid email address' }),
requiredString: () =>
z.string().min(1, { message: 'Cannot be empty' }),
nodeEnv: () =>
z.enum(['development', 'production', 'test']).default('development'),
commaSeparated: () =>
z
.string()
.default('')
.transform((val) =>
val
.split(',')
.map((s) => s.trim())
.filter(Boolean)
),
};
// Usage example
const envSchema = z.object({
NODE_ENV: envHelpers.nodeEnv(),
PORT: envHelpers.port(3000),
DATABASE_URL: envHelpers.requiredString(),
API_KEY: envHelpers.requiredString(),
DEBUG_MODE: envHelpers.boolean(false),
ALLOWED_ORIGINS: envHelpers.commaSeparated(),
ADMIN_EMAIL: envHelpers.email().optional(),
});
// Export the validated and typed environment
export const env = createEnv({ schema: envSchema });
// Type export for use elsewhere in the application
export type Env = typeof env;This utility solves a common pain point in Node.js applications: catching misconfigured environment variables early with clear, actionable error messages. Instead of discovering a missing DATABASE_URL deep in your application at runtime, you get immediate feedback at startup with a list of all issues.
The core createEnv function accepts a Zod object schema and validates process.env against it using safeParse. When validation fails, it transforms Zod's error format into a human-readable list showing each problematic variable, what went wrong, and what value was received (if any). The EnvValidationError class provides both a formatted console message and structured error data for programmatic access. The function returns a frozen object to prevent accidental mutations to your environment configuration.
The envHelpers object provides pre-built Zod schemas for common environment variable patterns. These handle the string-to-type coercion that's necessary because all environment variables are strings. The port helper parses integers and validates the valid port range. The boolean helper accepts common truthy/falsy string values. The commaSeparated helper transforms comma-delimited strings into arrays, which is useful for things like CORS origins or feature flags.
A key design decision is making the validated env object the single source of truth for environment access throughout your application. By exporting both the env object and its Env type, you get full TypeScript inference—your IDE will autocomplete env.DATABASE_URL and know it's a string. This eliminates the need for process.env.DATABASE_URL! assertions scattered throughout your codebase.
Use this pattern in any TypeScript Node.js application, especially those with multiple required configuration values. It's particularly valuable in serverless functions where cold starts might expose missing variables, and in team environments where new developers need clear guidance on required configuration. Avoid over-validating optional variables with complex rules—keep the schema focused on what's truly required for your app to function.