I Built a Zero-Dependency Env Validator with Full TypeScript Inference - Here's Why Developer Gavin Cettolo built envault, a zero-dependency environment variable validator for Node.js with full TypeScript inference. The tool validates, coerces, and types environment variables at startup, reporting all failures at once to prevent runtime errors from missing or misconfigured variables. It supports types, defaults, per-environment enforcement, custom validation, and secret masking to avoid leaking sensitive values in logs. Every Node.js project I've ever worked on has the same invisible vulnerability. Somewhere in the codebase, there's a line like this: js const db = new Client { url: process.env.DATABASE URL } It works in development. It works in staging. And then one day, in production, the variable is missing, misspelled in the CI config, forgotten in a new environment, silently dropped during a secrets rotation. The app starts. No error. No warning. Just undefined quietly making its way three layers deep into your database client until something downstream throws a cryptic connection error, and you spend forty minutes tracing it back to a missing environment variable. I got tired of this. So I built envault . envault validates, coerces, and types your environment variables at startup, and reports every failing variable at once, not one at a time. .env.example . array type, requiredIn for per-environment enforcement, and validate for custom logic. process.env.DATABASE URL // type: string | undefined That's it. That's the problem. TypeScript knows your env variable might be undefined . It tells you every time you use it. And most developers either suppress the warning with a non-null assertion or check it inline in every file that needs it, neither of which actually validates the value at startup. You don't find out DATABASE URL is broken when the app starts. You find out when the first request hits the database. envault moves that failure to the earliest possible moment: startup. Before your server binds to a port. Before your first route handler runs. Before any user touches your app. js import { check } from '@gavincettolo/envault' const env = check { DATABASE URL: { type: 'url', required: true }, PORT: { type: 'number', default: 3000 }, NODE ENV: { type: 'enum', values: 'development', 'production', 'test' as const }, API KEY: { type: 'string', required: true, secret: true }, } check does three things in one call: "3000" → 3000 , "true" → true . env.PORT is number , not string | undefined .If anything fails, the app throws immediately with every broken variable listed at once: envault: 2 environment variables failed validation: ✗ Missing required variable "DATABASE URL" ✗ "PORT" must be a number got "not-a-port" No more fixing one variable, redeploying, finding the next. npm install @gavincettolo/envault or pnpm add @gavincettolo/envault Create a dedicated env.ts file at the root of your project, one source of truth for all environment variables: js // src/env.ts import { check } from '@gavincettolo/envault' export const env = check { DATABASE URL: { type: 'url', required: true }, PORT: { type: 'number', default: 3000 }, NODE ENV: { type: 'enum', values: 'development', 'production', 'test' as const, default: 'development' }, JWT SECRET: { type: 'string', required: true, secret: true }, DEBUG: { type: 'boolean', default: false }, } Import env from this file anywhere in your codebase. TypeScript knows exactly what each field is, no casting, no non-null assertions, no surprises. js // src/server.ts import { env } from './env' app.listen env.PORT, = { // env.PORT is number, not string | undefined console.log Server running on port ${env.PORT} } Sensitive variables marked with secret: true never appear in error output, not even when validation fails: js const env = check { STRIPE SECRET KEY: { type: 'string', required: true, secret: true }, } // If missing: "Missing required variable STRIPE SECRET KEY" // The value is never logged, not even a partial one This matters more than it sounds. CI logs, error monitoring tools, and deployment output are not always private. A half-typed API key in a stack trace is still a leak. .env.example Schemas and example files drift apart the moment someone adds a new variable and forgets to update the docs. generateExample produces the file directly from the same schema check uses: js import { generateExample } from '@gavincettolo/envault' import { writeFileSync } from 'node:fs' import { schema } from './src/env.js' writeFileSync '.env.example', generateExample schema The output includes types, constraints, and descriptions as comments, so your .env.example is always accurate, because it can't drift from the schema: PostgreSQL connection string required, type: url DATABASE URL=https://example.com HTTP server port type: number PORT=3000 required, secret, type: string API KEY= js const env = check { WORKERS: { type: 'number', default: 4, min: 1, max: 32 }, SLUG: { type: 'string', required: true, pattern: /^ a-z0-9- +$/ }, } By default, check throws. You can override this for structured logging: js check schema, { onError errors { for const e of errors logger.fatal e process.exit 1 }, } For tests, override process.env entirely: js const env = check schema, { env: { PORT: '8080', DATABASE URL: 'https://db.example.com' }, } This was the part I spent the most time on. The goal was zero type annotations at the call site, the schema is the type definition. The schema is a discriminated union keyed on type : type FieldDef = | { type: 'string'; required?: boolean; default?: string; secret?: boolean; pattern?: RegExp } | { type: 'number'; required?: boolean; default?: number; min?: number; max?: number } | { type: 'boolean'; required?: boolean; default?: boolean } | { type: 'url'; required?: boolean; default?: string; secret?: boolean } | { type: 'enum'; values: readonly string ; required?: boolean; default?: string } | { type: 'json'; required?: boolean; default?: unknown } | { type: 'array'; required?: boolean; default?: string ; delimiter?: string; secret?: boolean } A conditional type maps each field definition to its output type: type Infer