Reading process.env.FOO directly everywhere gives you string | undefined and no validation. Parsing it once at startup with zod gives you a typed env object and a clear error if something is missing.

// env.ts
import { z } from "zod";

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
  SENTRY_DSN: z.string().url().optional(),
});

const parsed = EnvSchema.safeParse(process.env);

if (!parsed.success) {
  console.error(" invalid environment:");
  for (const issue of parsed.error.issues) {
    console.error(`  ${issue.path.join(".")}: ${issue.message}`);
  }
  process.exit(1);
}

export const env = parsed.data;
export type Env = typeof env;

Then everywhere else:

import { env } from "./env";

// env.PORT is number, env.DATABASE_URL is string, env.SENTRY_DSN is string|undefined
app.listen(env.PORT, () => console.log(`up on ${env.PORT}`));

z.coerce.number() turns "3000" into 3000 for you, which is the reason half of these schemas exist. Fail fast at boot; the worst kind of outage is “service ran for two hours before it tried to use the missing env var.”

See also /snippets/rust-safe-env-macro/.