type-envgen
type-envgen
Generate a typesafe, zod-validated environment module from your .env file.
type-envgen reads your .env, infers the type of every variable, and writes a TypeScript module that validates your environment once at startup and exports a fully typed env object. It works on the server (Node) and in the browser (Vite, Next.js, Astro, SvelteKit, Nuxt).
npx type-envgen init
Why
process.env.PORT is string | undefined, every time. Teams end up scattering Number(...), === "true" and non-null assertions across the codebase, and a missing variable only surfaces as a crash deep inside the app.
type-envgen moves that work to one generated file. Invalid or missing variables fail immediately, with a clear error listing every problem, and everything downstream gets real types.
Features
- Zero config: types are inferred from your values (numbers, booleans, URLs, emails, UUIDs, dates, IPs, JSON, and more).
- Watch mode:
-w, --watchregenerates the output automatically when your.envchanges. - Annotations: refine types with
@type,@default,@min,@max,@minlength,@maxlength,@patternand@optionalcomments. - Multiple targets: Node, Vite, Next.js, Astro, SvelteKit and Nuxt, each reading from the right source.
- Guided setup:
type-envgen initexplains each target and adds an npm script for you. - Typed JSON: JSON values get a real schema, not
any. - Fail fast: bad or missing variables throw a zod error at startup.
- Safe on real
.envfiles: values are only used to infer types and are never written to the output. - Tiny output: the generated file depends only on
zodandtype-envgen.
Requirements
- Node.js 20.12 or newer
- zod v4, added to your project automatically if missing (see below)
Installation
npm install type-envgen
The generated file imports zod and small helpers from type-envgen at runtime, so both must be regular dependencies of your app (not devDependencies). On first run, type-envgen checks your package.json and installs whichever is missing with your package manager (npm, pnpm, yarn or bun, detected from your lockfile):
zod and type-envgen not found in package.json dependencies, installing with npm...
✓ Installed zod and type-envgen
Prefer to manage it yourself? Pass --skip-install and the CLI only prints the install command. You can also run it without installing: npx type-envgen.
Quick start
1. Write (or annotate) your .env:
# @min 1
# @max 65535
PORT=3000
DATABASE_URL=postgres://user:pass@localhost:5432/app
DEBUG=false
# @type enum(debug, info, warn, error)
LOG_LEVEL=info
# @optional
SENTRY_DSN=https://abc@sentry.io/123
FEATURE_FLAGS={"beta":true,"maxUsers":100}
2. Generate the module:
npx type-envgen -o src/env.ts
# ✓ Generated src/env.ts from .env (6 variables)
3. Get this src/env.ts:
import { z } from "zod";
import { json, validateEnv } from "type-envgen";
export const envSchema = z.object({
DATABASE_URL: z.url(),
DEBUG: z.stringbool(),
FEATURE_FLAGS: json(z.object({ beta: z.boolean(), maxUsers: z.number() })),
LOG_LEVEL: z.enum(["debug","info","warn","error"]),
PORT: z.coerce.number().int().min(1).max(65535),
SENTRY_DSN: z.url().optional(),
});
export const env = validateEnv(envSchema, process.env);
export type Env = z.infer<typeof envSchema>;
4. Use it:
import { env } from "./env";
env.PORT; // number
env.DEBUG; // boolean
env.LOG_LEVEL; // "debug" | "info" | "warn" | "error"
env.SENTRY_DSN; // string | undefined
env.FEATURE_FLAGS.maxUsers; // number
Targets
A target decides where the generated module reads its values from. Browsers have no process.env, and each framework exposes client variables differently, so pick the one that matches where your code runs. The default is node.
| Target | Reads from | Client-exposed keys |
|---|---|---|
node |
process.env |
n/a (server only) |
vite |
import.meta.env |
VITE_* |
nextjs |
process.env.<KEY> |
NEXT_PUBLIC_* |
astro |
import.meta.env |
PUBLIC_* |
sveltekit |
$env/dynamic/public |
PUBLIC_* |
nuxt |
useRuntimeConfig().public |
NUXT_PUBLIC_* |
type-envgen .env.example -o src/env.ts --target vite
Target notes:
- Next.js only inlines literal
process.env.KEYreferences in client bundles, so the generated file lists every key explicitly instead of passingprocess.env. - Nuxt exposes public values as camelCase runtime config, so
NUXT_PUBLIC_API_URLbecomes the schema keyapiUrl. - Vite and Astro need the framework's client types for
import.meta.envto typecheck (vite/client, or Astro's generated types).
Client targets and secrets
Bundlers only expose prefixed variables, and anything in a client bundle is readable by every visitor. For client targets, type-envgen prints a warning for each key that lacks the target's prefix, because it would be undefined in the browser. Never put secrets in a variable that ends up in a client bundle.
warning: DATABASE_URL doesn't start with VITE_, so it is undefined in the browser. Never put secrets in a client bundle.
Interactive setup
npx type-envgen init
The wizard explains each target, asks which .env file to read and where to write the result, then offers to add npm scripts to your package.json and generates the file once:
{
"scripts": {
"env:generate": "type-envgen .env -o src/env.ts --target vite",
"env:check": "type-envgen .env -o src/env.ts --target vite --check",
"env:watch": "type-envgen .env -o src/env.ts --target vite --watch"
}
}
Use env:watch while developing and env:check in CI. Existing scripts with these names are never overwritten, and cancelling at any prompt writes nothing.
CLI reference
type-envgen [input] [options]
type-envgen init
| Argument / option | Description | Default |
|---|---|---|
input |
.env file to read |
.env |
-o, --output <path> |
File to write | env.ts |
--target <name> |
node, vite, nextjs, astro, sveltekit, nuxt |
node |
-w, --watch |
Watch input file and regenerate on changes | |
--skip-install |
Don't add zod/type-envgen to your dependencies | |
-h, --help |
Show help | |
-v, --version |
Show version |
type-envgen # .env -> env.ts
type-envgen --watch # regenerate automatically on changes
type-envgen .env.example -o src/env.ts # custom input and output
type-envgen --target nextjs # client-safe output for Next.js
type-envgen --help # everything the CLI can do
- Missing output folders are created; an existing output file is overwritten.
- Exit code
0on success,1on any error (missing input, unknown flag or target, invalid annotation, failed zod install).
Type inference
Untagged variables are inferred from their value. Rules are checked top to bottom; the first match wins.
| Example value | Schema | TypeScript type |
|---|---|---|
NODE_ENV=development |
z.enum(["development","production","test"]) |
union |
BASE_URL=http://${HOST} |
z.string() (references aren't resolved) |
string |
true / false |
z.stringbool() |
boolean |
3000, -42, 0 |
z.coerce.number().int() |
number |
3.14 |
z.coerce.number() |
number |
https://…, postgres://… |
z.url() |
string |
550e8400-e29b-41d4-a716-… |
z.uuid() |
string |
admin@example.com |
z.email() |
string |
2026-01-31 |
z.iso.date() |
string |
2026-01-31T10:00:00Z |
z.iso.datetime() |
string |
192.168.0.1 |
z.ipv4() |
string |
::1 |
z.ipv6() |
string |
{"a":1}, ["a","b"] |
json(<schema built from the value>) |
typed object/array |
| anything else (including empty) | z.string() |
string |
Good to know:
1and0are inferred as numbers, not booleans. Use# @type booleanif you mean a flag.- A comma-separated value like
a,b,cstays astring. - Every variable is required unless marked
# @optional.
Annotations
Put tags in comment lines directly above a key. Tags can be combined in any order.
# @type int
# @min 1
# @max 65535
PORT=3000
@type <type>
Overrides inference.
| Type | Schema |
|---|---|
string |
z.string() |
number |
z.coerce.number() |
int |
z.coerce.number().int() |
boolean |
z.stringbool() |
url |
z.url() |
email |
z.email() |
uuid |
z.uuid() |
date |
z.iso.date() |
datetime |
z.iso.datetime() |
ipv4 |
z.ipv4() |
ipv6 |
z.ipv6() |
json |
json(<schema built from the value>) |
enum(a, b, c) |
z.enum(["a","b","c"]) |
# @type boolean
VERBOSE=1
# @type enum(development, staging, production)
APP_ENV=staging
Type names are case-insensitive; enum values keep their case.
@min <n> / @max <n>
- On numbers: bounds on the value, e.g.
.min(1).max(65535). - On strings, URLs, emails, UUIDs, dates, IPs: bounds on the length, e.g.
z.string().min(1). - Work with inferred types too, no
@typeneeded.
# @min 32
JWT_SECRET=please-change-me-to-something-long-enough
@pattern <regex>
Validates that a string matches a regular expression, emitted as zod's .regex(...).
- Valid on strings, URLs, emails, UUIDs, dates and IPs. Using
@patternon numbers, booleans, or other types is an error. - Validates that the value in
.env(and any@default) matches the pattern at generation time, so typos and bad examples fail early. The value itself is never printed in the error. - Throws an error if the regex syntax is invalid, naming the offending key.
# @pattern ^sk_(live|test)_[A-Za-z0-9]+$
STRIPE_KEY=sk_test_abc123
# -> STRIPE_KEY: z.string().regex(/^sk_(live|test)_[A-Za-z0-9]+$/)
@minlength <n> / @maxlength <n>
Bounds on the length of string-like schemas (string, url, email, uuid, date, datetime, ipv4, ipv6). Emits .min(n) / .max(n) on the Zod schema. <n> must be a non-negative integer.
# @minlength 8
# @maxlength 64
JWT_SECRET=please-change-me-to-something-long-enough
# -> JWT_SECRET: z.string().min(8).max(64)
@min and @max on strings remain supported as aliases for backward compatibility.
@default <value>
Provides a fallback emitted as zod's .default(...), so the variable is never undefined when missing from the environment. The value is validated against the inferred or annotated type at generation time (for example, @default abc on an integer is an error).
# @default 3000
PORT=3000
# -> PORT: z.coerce.number().int().default(3000)
# @default info
LOG_LEVEL=info
# -> LOG_LEVEL: z.string().default("info")
@optional
The variable may be missing; its type becomes T | undefined.
# @optional
SENTRY_DSN=https://abc@sentry.io/123
Rules
- A blank line between the tags and the key cancels them.
- Regular comments (without
@) are ignored and can sit between tags. - Mistakes fail loudly instead of being silently ignored. Each of these stops generation with an error naming the key:
- unknown tag (
@mni) or type (@type nmber) - combining
@defaultand@optionalon the same key - invalid
@defaultvalue (non-integer on an int, out of bounds with@min/@maxor@minlength/@maxlength, invalid URL/email/UUID, invalid enum option, or invalid JSON) - empty
enum() - invalid regex syntax, empty pattern, or the
g/yflag on@pattern - the
.envvalue or@defaultnot matching@pattern @patternon non-string schemas (number,boolean,enum,json, etc.)- non-numeric
@min/@max, non-integer or negative@minlength/@maxlength, or min greater than max @min/@maxon aboolean,enumorjson, or@minlength/@maxlengthon non-string schemas
- unknown tag (
Loading env at runtime
For the node target, your .env must be loaded before the generated file is imported:
node --env-file=.env dist/index.js # Node 20.6+
import "dotenv/config"; // or with dotenv
import { env } from "./env";
Frameworks that load .env for you (Next.js, Remix, NestJS, and others) work out of the box on the server. For client targets, the framework or bundler injects the values at build time.
If a variable is missing or invalid, startup stops with one error listing every problem (values are never printed, since they may be secrets):
[type-envgen] Invalid environment variables:
DATABASE_URL: missing
PORT: must be a whole number
LOG_LEVEL: must be one of: debug, info, warn, error
Recommended workflow
- Run
npx type-envgen initonce to pick a target and add theenv:generate,env:watchandenv:checkscripts. - Generate from
.env.exampleso teammates and CI get the same types without real secrets. - Use
type-envgen --watchduring development soenv.tsstays in sync with your.env. - Commit the generated file and re-run
npm run env:generatewhenever you add or change a variable. - Add your own code to it freely. Re-running only refreshes the generated parts (the
type-envgenimport,envSchemaand theenvline) and keeps everything else. Edits made insideenvSchemaare replaced, so customize variables with annotations in your.envinstead. - In CI or a pre-commit hook, run with
--check(for example"env:check": "type-envgen .env.example -o src/env.ts --check") to verify the generated file is up to date without writing files or installing dependencies (exits0if up to date,1if out of date or missing).
Contributing
Contributions are welcome. Bug reports, ideas and pull requests all help.
- Questions or ideas? Start a thread in Discussions.
- Want to help? Look for
good first issueandhelp wantedissues. - Read the contributing guide for setup, git hooks, commit message format and PR rules.
- Please follow the Code of Conduct.
- Found a security issue? See SECURITY.md and please don't open a public issue.
- See the changelog for what changed in each version.
- Maintainers: see docs/releasing.md for how releases work.
License
MIT © OlaleyeFisayo
Related Packages
Type-safe, validated environment variables for every JavaScript runtime and fram...
A universal dev tool for viewing, overriding, and swapping environment variables...
SEO and GEO audit tool for developers. Audits Next.js, Astro, SvelteKit, Nuxt, a...
Create a blog wired to ContioReach in one command. Pick Next.js, Nuxt, Astro, Sv...
Version History
| Version | Released | License |
|---|---|---|
| 2.0.0 | MIT | |
| 1.2.0 | MIT | |
| 1.1.0 | MIT | |
| 1.0.1 | MIT | |
| 1.0.0 | MIT |
Pre-releases (1)
| Version | Released | License |
|---|---|---|
| 0.0.0-stage |