typescript 70 lines · 3 tabs

Typed env parsing with zod

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

const booleanFromString = z.preprocess((val) => {
  if (typeof val !== "string") return val;
  return ["true", "1", "yes", "on"].includes(val.toLowerCase());
}, z.boolean());

const envSchema = z
  .object({
    NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
    PORT: z.coerce.number().int().positive().default(3000),
    LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
    DATABASE_URL: z.string().url(),
    DATABASE_SSL: booleanFromString.default(false),
    DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(50).default(10),
    JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 chars"),
  })
  .refine((cfg) => cfg.NODE_ENV !== "production" || cfg.DATABASE_SSL, {
    message: "DATABASE_SSL must be enabled in production",
    path: ["DATABASE_SSL"],
  });

export type Env = z.infer<typeof envSchema>;

function parseEnv(): Env {
  const result = envSchema.safeParse(process.env);
  if (!result.success) {
    const details = result.error.issues
      .map((i) => `  - ${i.path.join(".") || "(root)"}: ${i.message}`)
      .join("\n");
    console.error(`\u274c Invalid environment configuration:\n${details}`);
    process.exit(1);
  }
  return result.data;
}

export const env: Env = Object.freeze(parseEnv());
3 files · typescript Explain with highlit

This snippet shows a common production pattern: validating and parsing environment variables once, at process startup, into a strongly-typed frozen config object. Reading process.env directly throughout an app is fragile because every value is string | undefined, coercion is ad hoc, and a missing variable typically fails deep inside runtime code far from its cause. Centralizing parsing with a schema turns those runtime surprises into a single fail-fast check with a readable error report.

In env.ts, the schema is declared with z.object. Because process.env values are always strings, numeric and boolean fields use z.coerce.number() and a custom booleanFromString helper rather than z.number() directly — coercion is the key detail people miss when first wiring Zod to env vars. Defaults such as PORT and LOG_LEVEL are expressed with .default(...), and z.enum constrains NODE_ENV to a known set. A .refine at the end enforces a cross-field rule: DATABASE_SSL must be enabled in production. The parsed result is exposed through parseEnv, which on failure formats error.issues into a multi-line message and calls process.exit(1) so misconfiguration never boots a half-working server.

The booleanFromString helper in the same file uses z.preprocess to accept the loose set of truthy strings ("true", "1", "yes") that real deployment tooling emits, keeping the boolean logic in one place.

Because env is derived via z.infer<typeof envSchema>, the exported type stays in perfect sync with the schema — adding a field updates the type automatically with no duplicate interface to maintain. Object.freeze prevents accidental mutation later.

In config.ts, the validated env is consumed to build higher-level config objects. Nothing downstream ever touches process.env again; instead config.db and config.server carry already-typed, already-validated values, so editors autocomplete fields and TypeScript rejects typos.

Finally, server.ts imports config and starts listening. Importing env.ts at the top of the module graph means validation runs before any request handling. The trade-off is a slightly stricter startup contract: every required variable must be present or the process refuses to boot — which is usually exactly the behavior wanted for infrastructure config, and far safer than discovering a missing secret under production load.


Related snips

Share this code

Here's the card — post it anywhere.

Typed env parsing with zod — share card
Link copied