The Problem: Silent Failures from Missing Env Vars
Every Node.js project has this line somewhere:
const db = new Client({ url: process.env.DATABASE_URL })
It works in dev, staging, then one day in production the variable is missing. The app starts, no error, just undefined making its way into the database client until a cryptic connection error surfaces 40 minutes later.
TypeScript knows process.env.DATABASE_URL is string | undefined, but most developers suppress that with non-null assertions or inline checks. Neither validates at startup.
envault: Validate, Coerce, Type at Startup
@gavincettolo/envault (v0.2.0) does three things in one check() call:
- Validates every variable against a schema
- Coerces raw strings into correct types (
"3000"→3000) - Returns a fully typed object
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 },
})
If validation 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")
Installation and Setup
npm install @gavincettolo/envault
Create a dedicated 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 },
})
Use env anywhere:
import { env } from './env'
app.listen(env.PORT, () => {
// env.PORT is number, not string | undefined
console.log(`Server running on port ${env.PORT}`)
})
Key Features
Secret Masking
Variables marked secret: true never appear in error output — even partial values are hidden. This prevents leaks in CI logs or error monitoring tools.
Auto-Generated .env.example
generateExample() produces an accurate .env.example from the same schema, preventing drift:
import { generateExample } from '@gavincettolo/envault'
import { writeFileSync } from 'node:fs'
import { schema } from './src/env.js'
writeFileSync('.env.example', generateExample(schema))
Number Constraints and Pattern Validation
const env = check({
WORKERS: { type: 'number', default: 4, min: 1, max: 32 },
SLUG: { type: 'string', required: true, pattern: /^[a-z0-9-]+$/ },
})
Custom Error Handler and Test-Friendly Env Source
Override error handling:
check(schema, {
onError(errors) {
for (const e of errors) logger.fatal(e)
process.exit(1)
},
})
Override process.env for tests:
const env = check(schema, {
env: { PORT: '8080', DATABASE_URL: 'https://db.example.com' },
})
TypeScript Inference: How It Works
The schema is a discriminated union keyed on type. A conditional type maps each field definition to its output type:
type Infer =
F['type'] extends 'string' ? string :
F['type'] extends 'number' ? number :
F['type'] extends 'boolean' ? boolean :
F['type'] extends 'array' ? string[] :
F extends { type: 'enum'; values: infer V }
? V extends readonly string[] ? V[number] : string
: never
For enum with as const, TypeScript captures literal tuple types, yielding 'dev' | 'prod' instead of string. Optional fields are T | undefined unless required: true or default is set.
Why Not dotenv + Zod?
| Feature | envault | dotenv + zod | envalid |
|---|---|---|---|
| Zero dependencies | ✓ | ✗ | ✗ |
| Full TS inference | ✓ | ✓ | partial |
| Aggregated errors | ✓ | ✓ | ✓ |
| Secret masking | ✓ | ✗ | ✗ |
| .env.example generator | ✓ | ✗ | ✗ |
| ESM + CJS dual build | ✓ | ✓ | ✓ |
envault is purpose-built for environment variables. If you already use Zod heavily, dotenv + Zod is a legitimate choice. envault offers zero dependencies and a single function that handles everything together.
What's New in v0.2.0
Array Type
Parse comma-separated lists directly:
const env = check({
ALLOWED_ORIGINS: { type: 'array', required: true },
TAGS: { type: 'array', default: [], delimiter: '|' },
})
// ALLOWED_ORIGINS=a.com, b.com, c.com → ['a.com', 'b.com', 'c.com']
requiredIn: Per-Environment Enforcement
const env = check(
{
DATABASE_URL: { type: 'url', requiredIn: ['production', 'staging'] },
SENTRY_DSN: { type: 'url', requiredIn: ['production'] },
},
{ environment: process.env.NODE_ENV },
)
Variables required only in specific environments — no scattered conditional logic.
Custom validate Function
const env = check({
PORT: {
type: 'number',
required: true,
validate: (n) => n > 1024 ? true : 'must be a non-privileged port (> 1024)',
},
})
Return true to pass, or a string for a custom error message.
What's Next
The author is considering:
generateExample()as a CLI command (npx envault generate)- Watch mode for reload on
.envchanges - Integration examples for Next.js, Fastify, Express
If you need these, open an issue on GitHub.
Getting Started
- Install:
npm install @gavincettolo/envault - Create
src/env.tswith acheck()call - Import
envfrom that file everywhere - Optionally add
generateExample()to a prebuild script
envault moves env validation to startup, catches all failures at once, and keeps secrets out of logs.




