javascript 68 lines · 3 tabs

Validate Environment Variables at Startup with a Zod Schema in Node.js

Shared by codesnips Sep 2026
3 tabs
const { z } = require('zod');

const envSchema = z
  .object({
    NODE_ENV: z
      .enum(['development', 'test', 'production'])
      .default('development'),
    PORT: z.coerce.number().int().positive().default(3000),
    DATABASE_URL: z.string().url(),
    REDIS_URL: z.string().url().optional(),
    JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 chars'),
    LOG_LEVEL: z
      .enum(['debug', 'info', 'warn', 'error'])
      .default('info'),
    SENTRY_DSN: z.string().url().optional(),
  })
  .superRefine((env, ctx) => {
    if (env.NODE_ENV === 'production' && !env.SENTRY_DSN) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        path: ['SENTRY_DSN'],
        message: 'SENTRY_DSN is required in production',
      });
    }
  });

module.exports = { envSchema };
3 files · javascript Explain with highlit

This snippet shows the fail-fast configuration pattern: instead of scattering process.env.SOMETHING reads across a codebase and discovering a missing or malformed secret at 3am in production, the environment is parsed and validated exactly once at boot against a schema. If validation fails, the process refuses to start with a readable error listing every problem. This turns a whole class of runtime surprises into an immediate, loud startup failure.

In env.schema.js the shape of the environment is described with zod. Each variable is coerced and constrained: PORT uses z.coerce.number() because env vars are always strings, NODE_ENV is an enum with a default, DATABASE_URL must be a valid URL, and secrets like JWT_SECRET enforce a minimum length so a weak key can't slip through. The z.coerce helpers matter here — without them a numeric-looking string stays a string and downstream code silently misbehaves. A superRefine block adds a cross-field rule that only production requires SENTRY_DSN, which a flat per-field schema can't express.

In config.js the schema is applied. dotenv loads a .env file first so local development works, then envSchema.safeParse(process.env) runs. safeParse is used rather than parse so the code can format the errors itself: flatten().fieldErrors produces a compact per-field map that is printed before calling process.exit(1). The parsed result is frozen with Object.freeze and exported as config, giving the rest of the app a single typed, immutable source of truth. Because this file runs its validation at module load, simply requiring it triggers the check.

In server.js the very first line imports config, guaranteeing the guard runs before any server or database connection is attempted. The rest of the app reads config.PORT and config.DATABASE_URL instead of process.env, so values are already the right types and are known to exist. The trade-off is a small amount of boot-time ceremony and one dependency, in exchange for eliminating an entire category of latent misconfiguration bugs. This pattern is worth reaching for in any service deployed across multiple environments.


Related snips

Share this code

Here's the card — post it anywhere.

Validate Environment Variables at Startup with a Zod Schema in Node.js — share card
Link copied