Zod & env-schema Validation
Zod validates process.env at Node.js boot so misconfigured deploys fail before accepting traffic. One schema gives you runtime checks and TypeScript types without drift.
Search across all documentation pages
Zod validates process.env at Node.js boot so misconfigured deploys fail before accepting traffic. One schema gives you runtime checks and TypeScript types without drift.
Quick-reference recipe card - copy-paste ready.
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
export type Env = z.infer<typeof envSchema>;
export const env = envSchema.parse(process.env);# CI: validate example env file
npx tsx -e "import { envSchema } from './src/env'; envSchema.parse(require('dotenv').config({path:'.env.example'}).parsed)"When to reach for this:
undefined database URLs at runtime.env.example// src/env.ts
import { z } from "zod";
const envSchema = z
.object({
NODE_ENV: z.enum(["development", "test", "production"]),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
CACHE_TTL_SECONDS: z.coerce.number().positive().default(300),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
})
.refine(
(e) => e.NODE_ENV !== "production" || e.OTEL_EXPORTER_OTLP_ENDPOINT,
{ message: "OTEL_EXPORTER_OTLP_ENDPOINT required in production", path: ["OTEL_EXPORTER_OTLP_ENDPOINT"] }
);
export type Env = z.infer<typeof envSchema>;
function formatZodError(error: z.ZodError): string {
return error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("\n");
}
export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
const result = envSchema.safeParse(source);
if (!result.success) {
console.error("Invalid environment configuration:\n" + formatZodError(result.error));
process.exit(1);
}
return result.data;
}
export const env = loadEnv();
// src/main.ts
import { env } from "./env";
import express from "express";
const app = express();
app.get("/health", (_req, res) => res.json({ ok: true, env: env.NODE_ENV }));
app.listen(env.PORT);What this demonstrates:
safeParse + process.exit(1) gives operator-friendly error outputz.coerce.number() handles string ports from Kubernetes env.refine enforces production-only requirementsz.infer exports Env for dependency injection typingparse throws ZodError; safeParse returns { success, data | error }loadEnv() call| Pattern | Use case |
|---|---|
z.coerce.number() | PORT, pool sizes, timeouts |
z.coerce.boolean() | Rare - prefer enum "true"/"false" for clarity |
.default() | Sane dev defaults; omit in prod docs |
.optional() | Truly optional integrations |
.transform() | Parse JSON env blobs |
.refine() | Cross-field rules |
// JSON config blob in one env var (use sparingly)
const featureFlagsSchema = z
.string()
.default("{}")
.transform((s, ctx) => {
try {
return JSON.parse(s) as Record<string, boolean>;
} catch {
ctx.addIssue({ code: "custom", message: "FEATURE_FLAGS_JSON must be valid JSON" });
return z.NEVER;
}
});The env-schema package integrates with Fastify and JSON Schema. Zod is more common in TypeScript-first Express/Nest codebases and composes with request body schemas.
// Pass Env into factories - never pass whole process.env
export function createDbPool(cfg: Pick<Env, "DATABASE_URL">) {
return new Pool({ connectionString: cfg.DATABASE_URL });
}env.ts exits on missing vars. Fix: loadEnv(testEnv) with reset helper or vi.stubEnv..default("changeme") on JWT_SECRET. Fix: No defaults on secrets; fail if absent in production.console.log(env) leaks secrets. Fix: Log keys only or use a redacted serializer.dbEnv, authEnv) and merge with .merge()..env.example - Example file drifts from schema. Fix: CI job parses .env.example through the schema.| Alternative | Use When | Don't Use When |
|---|---|---|
env-schema + JSON Schema | Fastify-native projects | You already standardize on Zod at HTTP boundary |
convict | Hierarchical config with docs | Simple 10-var services |
Manual if (!process.env.X) | 2-var scripts | Production APIs |
dotenv-safe | Enforcing .env keys locally | Production (platform injects env) |
Use safeParse at the application entry when you want formatted logs before exit. Either is fine if a global handler formats ZodError.
Pass a fixture object to loadEnv({ DATABASE_URL: "postgres://..." }) and reset cached config between tests.
Yes. Validate process.env in ConfigModule.forRoot({ validate }) using the same Zod schema.
Use .refine on NODE_ENV === "production" or discriminated unions on NODE_ENV.
Not built-in. Maintain .env.example manually or script from schema metadata you add as .describe().
Zod does not echo values by default in issues. Avoid printing result.error.flatten().fieldErrors alongside raw process.env dumps.
Same schema, different values. Optional fields can differ if .refine encodes environment-specific rules.
Base64-encode in env or use secrets manager file mount; Zod validates presence and minimum length, not format of newlines.
Boolean("false") is true in JavaScript. Prefer z.enum(["true","false"]).transform(v => v === "true").
When keys rotate weekly or compliance forbids env vars in k8s manifests. Zod still validates resolved values after fetch.
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.
Reviewed by Chris St. John·Last updated Jul 16, 2026