typescript 104 lines · 3 tabs

Type-Safe Environment Variable Parsing and Validation With Zod

Shared by codesnips Jul 2026
3 tabs
import { z } from "zod";

const booleanFromString = z
  .string()
  .transform((v) => v.trim().toLowerCase())
  .pipe(z.enum(["true", "false", "1", "0"]))
  .transform((v) => v === "true" || v === "1");

export const envSchema = z
  .object({
    NODE_ENV: z
      .enum(["development", "test", "production"])
      .default("development"),
    PORT: z.coerce.number().int().positive().max(65535).default(3000),
    DATABASE_URL: z.string().url(),
    DB_POOL_SIZE: z.coerce.number().int().min(1).max(100).default(10),
    LOG_LEVEL: z
      .enum(["debug", "info", "warn", "error"])
      .default("info"),
    SESSION_SECRET: z.string().min(1),
    METRICS_ENABLED: booleanFromString.default(false),
  })
  .superRefine((env, ctx) => {
    if (env.NODE_ENV === "production" && env.SESSION_SECRET.length < 32) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        path: ["SESSION_SECRET"],
        message: "must be at least 32 characters in production",
      });
    }
  });

export type ParsedEnv = z.infer<typeof envSchema>;
3 files · typescript Explain with highlit

This snippet shows how a Node service turns the untyped, all-string process.env into a validated, strongly-typed configuration object that the rest of the application can trust. The core idea is fail-fast configuration: rather than discovering a missing or malformed variable deep inside request handling, the process refuses to boot when the environment is invalid, and it surfaces every problem at once.

In env.schema.ts, a Zod schema describes the shape of the environment. Because environment variables always arrive as strings, the schema leans on coercion and transforms: z.coerce.number() parses PORT, and a small booleanFromString helper maps "true"/"1" to real booleans. NODE_ENV is constrained to an enum with a default, DATABASE_URL must be a valid URL, and LOG_LEVEL falls back to a sane value. A superRefine block encodes a cross-field rule that a single-field validator cannot express: in production, SESSION_SECRET must be long enough, which catches weak secrets before they ship.

In config.ts, loadConfig() runs envSchema.safeParse(process.env) and inspects the result. On failure it walks result.error.issues, formats each path and message into a readable list, and throws a single aggregated error — so an operator sees all misconfigurations in one log line instead of fixing them one boot at a time. On success it maps the flat parsed values into a nested, domain-shaped object (server, db, auth) that reads naturally at call sites. The module memoizes the result in cached so parsing happens once, and it exports an inferred AppConfig type via z.infer so the config and its type never drift apart.

In server.ts, getConfig() is called at startup; if it throws, the process exits with a non-zero code before binding a port. This is the key trade-off: strictness at boot in exchange for confidence at runtime. Downstream code receives config.server.port as a number and config.auth.sessionSecret as a guaranteed non-empty string, with no defensive parsing or optional-chaining scattered around. The pattern suits any twelve-factor service, and the main pitfall to watch is keeping the schema authoritative — new variables belong in the schema first, not read ad hoc from process.env.


Related snips

Share this code

Here's the card — post it anywhere.

Type-Safe Environment Variable Parsing and Validation With Zod — share card
Link copied