Every Node.js project I've ever worked on has the same invisible vulnerability.
Somewhere in the codebase, there's a line like this:
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.
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
pnpm add @gavincettolo/envault
Create a dedicated env.ts
file at the root of your project, one source of truth for all environment variables:
// 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.
// 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:
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:
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:
DATABASE_URL=https://example.com
PORT=3000
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:
check(schema, {
onError(errors) {
for (const e of errors) logger.fatal(e)
process.exit(1)
},
})
For tests, override process.env
entirely:
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<F extends FieldDef> =
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
The enum
case is where it gets interesting. If you pass values: ['dev', 'prod'] as const
, TypeScript captures the literal tuple type, and V[number]
collapses it into 'dev' | 'prod'
, not a loose string
. Without as const
, you lose that precision. It's a sharp edge worth knowing about, and it's documented clearly in the README.
Optionality follows the same pattern, a field is non-nullable if it's required: true
or has a default
, otherwise it's T | undefined
:
type Env<S extends Schema> = {
readonly [K in keyof S]: IsRequired<S[K]> extends true
? Infer<S[K]>
: Infer<S[K]> | undefined
}
The result: check()
is generic over the schema shape. TypeScript captures it at the call site. No casting, no as
, no manual type declarations duplicating what's already in the schema.
The honest answer: you can, and it works well.
But it's two dependencies, around twenty lines of glue code, and a schema that lives separately from your .env.example
, which means it will eventually drift.
Here's how envault compares:
| envault | dotenv + zod | envalid | |
|---|---|---|---|
| Zero dependencies | β | β | β |
| Full TS inference | β | β | partial |
| Aggregated errors | β | β | β |
| Secret masking in errors | β | β | β |
.env.example generator |
|||
| β | β | β | |
| ESM + CJS dual build | β | β | β |
The trade-off is scope. envault
doesn't try to be a general-purpose schema validator like Zod. It does one thing, environment variables, and tries to do it completely. If you're already using Zod heavily in your project and want to unify your validation tooling, dotenv + zod
is a legitimate choice.
If you want zero dependencies and a single function call that handles validation, coercion, typing, secret masking, and example generation together, that's what envault is for.
Three additions that came out of real usage patterns.
A very common pattern in env files is comma-separated lists: CORS origins, feature flags, allowed IPs. Previously you'd parse these manually after reading the variable. Now:
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']
// TAGS=frontend|backend|infra β ['frontend', 'backend', 'infra']
The default delimiter is ,
with automatic whitespace trimming. Override it with delimiter
for any other separator.
requiredIn
- per-environment enforcement
Some variables are mandatory in production but optional locally.
requiredIn
lets you express that directly in the schema:
const env = check(
{
DATABASE_URL: { type: 'url', requiredIn: ['production', 'staging'] },
SENTRY_DSN: { type: 'url', requiredIn: ['production'] },
REDIS_URL: { type: 'url', requiredIn: ['production', 'staging'] },
},
{ environment: process.env.NODE_ENV },
)
In development, these variables are optional. In production and staging, missing any of them throws before the app starts. No more environment-specific validation logic scattered across your codebase.
validate
- custom validation logic
For checks that go beyond what the built-in options cover, every field type now accepts a
validate
function. Return true
to pass, or a string to fail with a specific message:
const env = check({
PORT: {
type: 'number',
required: true,
validate: (n) => n > 1024 ? true : 'must be a non-privileged port (> 1024)',
},
DATABASE_URL: {
type: 'url',
required: true,
validate: (url) => url.startsWith('postgres://') ? true : 'must be a PostgreSQL connection string',
},
})
The error message you return becomes part of the aggregated error output, same format, same clarity.
A few things I'm actively thinking about:
generateExample()
as a CLI commandnpx envault generate
in CI without a script file..env
file changes in development.src/env.ts
pattern with generateExample
in a prebuild
script.If any of these would be useful for your setup, open an issue or start a discussion on GitHub, I'm building this around real usage patterns, and feedback on what actually matters is more valuable than a roadmap I made up alone.
Zero-dependency environment variable validation with full TypeScript inference.
process.env.DATABASE_URL
is silently undefined
at runtime, and nobody finds out until the app crashes in production. envault
validates, coerces, and types your environment variables at startup β and reports every problem at once.
import { check } from '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 },
})
// env.PORT β number
// env.DATABASE_URL β string
// env.NODE_ENV β 'development' | 'production' | 'test' | undefined
// env.API_KEY β string
If validation fails, you get a clear, aggregated error β not aβ¦
What's your current setup for environment variable validation?
Still using raw process.env
? Zod schema? Something else? Drop it in the comments: I'm curious how different teams solve this, and whether there are edge cases envault should handle that it doesn't yet.
If this was useful, a β€οΈ or a π¦ means a lot, and if you want to follow the project, a star on GitHub goes a long way for a new open-source library.