zodenvy
v1.1.0
Published
Generate type-safe, Zod-validated TypeScript schemas from your .env. CLI that produces autocomplete, runtime validation, and a synced .env.template.
Maintainers
Readme
zodenvy
Generate type-safe, Zod-validated TypeScript schemas from your .env file. One command — autocomplete, runtime validation, and a .env.template kept automatically in sync.
Install
npm install --save-dev zodenvy zod
# or
pnpm add -D zodenvy zod
# or
yarn add -D zodenvy zodzod is a peer dependency (≥ 3.22).
Quick start
Given a .env like this:
API_URL=https://api.example.com
DB_PORT=5432
DEBUG=false
# @default production
NODE_ENV=Run:
npx zodenvyYou get src/constants/env.generated.ts:
// Auto-generated by zodenvy — DO NOT EDIT
import { z } from "zod";
export const ENV_NAMES = ["API_URL", "DB_PORT", "DEBUG", "NODE_ENV"] as const;
export const envSchema = z.object({
API_URL: z.string().min(1),
DB_PORT: z.coerce.number(),
DEBUG: z.enum(["true", "false"]).transform((v) => v === "true"),
NODE_ENV: z.string().default("production"),
});
export type EnvironmentVariables = z.infer<typeof envSchema>;
export const getEnvs = (): EnvironmentVariables => {
return envSchema.parse({
API_URL: process.env.API_URL,
DB_PORT: process.env.DB_PORT,
DEBUG: process.env.DEBUG,
NODE_ENV: process.env.NODE_ENV,
});
};The accessor uses static literal process.env.FOO reads (not a process.env[name] loop) so it works correctly with every bundler that statically inlines env vars — Webpack/Next.js DefinePlugin, Vite, esbuild, Rollup.
…and a .env.template next to your .env, ready to commit.
CLI options
| Flag | Default | Description |
| -------------------- | ---------------------- | ---------------------------------------------------------------------------------- |
| --out-dir <dir> | src/constants | Output directory (relative to the .env file, or absolute) |
| --out-file <file> | env.generated.ts | Output filename |
| --framework <name> | auto | auto | node | next | vite | astro | cloudflare | deno | bun |
| --env-source <s> | auto | Legacy: auto | process.env | import.meta.env (overrides framework) |
| --type-name <name> | EnvironmentVariables | Exported TypeScript type name |
| --env-file <path> | ./.env | Path to the .env file |
| -r, --recursive | false | Walk the current directory tree and process every package's .env (monorepo) |
| -h, --help | — | Show usage |
| -v, --version | — | Show installed version |
Framework support
--framework auto (the default) inspects the neighboring package.json and picks an adapter:
| Adapter | Detected by | Accessor | Server/client split |
| ------------ | -------------------------------------------- | -------------------------- | ------------------------------ |
| next | next dependency | process.env.FOO | NEXT_PUBLIC_* → public split |
| astro | astro dependency | import.meta.env.FOO | PUBLIC_* → public split |
| vite | vite / @vitejs/* dependency | import.meta.env.FOO | VITE_* → public split |
| cloudflare | wrangler / @cloudflare/workers-types dep | env.FOO (request-scoped) | factory: createGetEnvs(env) |
| bun | bun / bun-types / @types/bun dep | Bun.env.FOO | — |
| deno | (explicit --framework deno) | Deno.env.get("FOO") | — |
| node | fallback | process.env.FOO | — |
When an adapter has a server/client split (Next.js, Vite, Astro), the generated file additionally exports publicEnvSchema, PublicEnvironmentVariables, and getPublicEnvs() — a subset containing only variables whose name starts with the framework's public prefix. Import getPublicEnvs() in client code, getEnvs() on the server.
Override the framework explicitly with --framework next (or any name above). The legacy --env-source flag still works and takes precedence — handy if you have an old config you don't want to touch.
Annotations
Comments directly above a variable override the inferred type:
# @type number
SOME_STRING_THAT_IS_ACTUALLY_A_PORT=8080
# @optional
FEATURE_FLAG=
# @default production
NODE_ENV=| Annotation | Effect |
| --------------------------------- | ----------------------------- |
| # @type number\|boolean\|string | Override the inferred type |
| # @optional | Mark as optional |
| # @default <value> | Set default, implies optional |
Annotations are cleared by blank lines — they apply only to the immediately following variable.
Type inference defaults:
| Value | Inferred type |
| ---------------- | ------------------ |
| true / false | boolean |
| 3000, 0.5 | number |
| anything else | string |
| (empty) | string, optional |
Runtime usage
Option A — use the generated getEnvs
The generated file already exports getEnvs:
import { getEnvs } from "./constants/env.generated";
const env = getEnvs();
console.log(env.DB_PORT); // numberNext.js: server vs client
With --framework next the generated file exports both getEnvs (full schema, server-only) and getPublicEnvs (only NEXT_PUBLIC_* vars, safe to call in the browser):
// app/page.tsx (Server Component) — full env
import { getEnvs } from "@/constants/env.generated";
const env = getEnvs();
fetch(`${env.API_URL}/data`, { headers: { Authorization: env.DB_PASSWORD } });
// app/header.tsx ("use client") — only public env
("use client");
import { getPublicEnvs } from "@/constants/env.generated";
const publicEnv = getPublicEnvs();
console.log(publicEnv.NEXT_PUBLIC_API_URL);Because the generated accessor uses static process.env.NEXT_PUBLIC_FOO reads (not a dynamic loop), Next.js's webpack DefinePlugin inlines the values at build time and the client bundle gets real strings — no more undefined.
Cloudflare Workers — request-scoped env
With --framework cloudflare the generated file exports a createGetEnvs factory instead, because Workers receive env from the request context:
// src/index.ts
import { createGetEnvs } from "./constants/env.generated";
export default {
async fetch(req: Request, env: Env) {
const envs = createGetEnvs(env as Record<string, string | undefined>)();
return new Response(envs.API_URL);
},
};Option B — createGetEnvs factory (any runtime)
For any runtime where the built-in adapters don't fit (Deno without --framework deno, an embedded engine, a test harness), import the runtime factory and pass your own env source:
import { createGetEnvs } from "zodenvy";
import { envSchema } from "./constants/env.generated";
export const getEnvs = createGetEnvs(envSchema, import.meta.env);
// or: createGetEnvs(envSchema, Deno.env.toObject())
// or: createGetEnvs(envSchema, request.env)createGetEnvs parses and validates on the first call, then returns the cached result. It accepts any Record<string, string | undefined> as the source — fully framework-agnostic.
.env.template sync
Every run automatically syncs .env.template next to your .env:
- New variables are appended with an empty value
- Variables removed from
.envare removed from the template
Commit .env.template to version control so teammates know which variables are required.
Monorepo mode (--recursive)
For monorepos, run from the repository root:
npx zodenvy --recursiveThe CLI walks the current directory, finds every .env whose folder contains a src/ directory, and for each one:
- writes
<out-dir>/<out-file>next to the.env - syncs
.env.templatenext to the.env
Ignored: node_modules, dist, build, .git, .next, .turbo, coverage, and any directory whose name starts with ..
Programmatic API
zodenvy also exports the building blocks if you want to script your own pipeline or build a custom framework adapter:
import {
EnvParser,
CodeGenerator,
TemplateSync,
createGetEnvs,
nodeAdapter,
nextAdapter,
cloudflareAdapter,
type Adapter,
} from "zodenvy";EnvParser— parses.envcontent into typed entries.CodeGenerator— turns entries into the generated TypeScript source (accepts anadapteroption).TemplateSync— keeps.env.templatein sync with the parsed entries.createGetEnvs— runtime factory that parses, validates and caches.nodeAdapter,nextAdapter,viteAdapter,astroAdapter,cloudflareAdapter,denoAdapter,bunAdapter— built-in framework adapters.Adapter— type for custom adapters; pass one toCodeGeneratorto emit any accessor pattern.
License
MIT © Patryk Barć
