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.
Recipe
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:
- Any service with more than 3 env vars
- Staging/prod parity checks in CI
- Preventing
undefineddatabase URLs at runtime - Teams onboarding new engineers who copy
.env.example
Working 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.refineenforces production-only requirementsz.inferexportsEnvfor dependency injection typing
Deep Dive
How It Works
- Environment variables are always strings (or undefined); Zod coerces and validates
parsethrowsZodError;safeParsereturns{ success, data | error }- Schema runs once at module load or explicit
loadEnv()call - Invalid config never reaches route handlers or DB pools
Common Zod Patterns for Env
| 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;
}
});env-schema Alternative
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.
TypeScript Notes
// Pass Env into factories - never pass whole process.env
export function createDbPool(cfg: Pick<Env, "DATABASE_URL">) {
return new Pool({ connectionString: cfg.DATABASE_URL });
}Gotchas
- Parsing env on every test file import - Order matters if
env.tsexits on missing vars. Fix:loadEnv(testEnv)withresethelper orvi.stubEnv. - Defaults that hide missing prod secrets -
.default("changeme")onJWT_SECRET. Fix: No defaults on secrets; fail if absent in production. - Logging parsed env -
console.log(env)leaks secrets. Fix: Log keys only or use a redacted serializer. - Huge schemas in one file - 40 vars become unmaintainable. Fix: Split by domain (
dbEnv,authEnv) and merge with.merge(). - Skipping CI validation of
.env.example- Example file drifts from schema. Fix: CI job parses.env.examplethrough the schema.
Alternatives
| 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) |
FAQs
Should I use parse or safeParse?
Use safeParse at the application entry when you want formatted logs before exit. Either is fine if a global handler formats ZodError.
How do I test code that imports env?
Pass a fixture object to loadEnv({ DATABASE_URL: "postgres://..." }) and reset cached config between tests.
Does Zod work with NestJS ConfigModule?
Yes. Validate process.env in ConfigModule.forRoot({ validate }) using the same Zod schema.
How do I validate optional production-only fields?
Use .refine on NODE_ENV === "production" or discriminated unions on NODE_ENV.
Can I generate .env.example from Zod?
Not built-in. Maintain .env.example manually or script from schema metadata you add as .describe().
What about secrets in Zod error messages?
Zod does not echo values by default in issues. Avoid printing result.error.flatten().fieldErrors alongside raw process.env dumps.
Should staging use the same schema as production?
Same schema, different values. Optional fields can differ if .refine encodes environment-specific rules.
How do I handle multiline PEM keys?
Base64-encode in env or use secrets manager file mount; Zod validates presence and minimum length, not format of newlines.
Is z.coerce.boolean() safe for env?
Boolean("false") is true in JavaScript. Prefer z.enum(["true","false"]).transform(v => v === "true").
When should I switch to secrets managers?
When keys rotate weekly or compliance forbids env vars in k8s manifests. Zod still validates resolved values after fetch.
Related
- Configuration Basics - twelve-factor overview
- dotenv vs Platform Inject - where values come from
- Secrets Managers - runtime secret fetch
- Configuration Best Practices - team standards
- Zod - library basics
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.