Parsing process.env with zod, once, at startup
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/.