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?

Featureenvaultdotenv + zodenvalid
Zero dependencies
Full TS inferencepartial
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 .env changes
  • Integration examples for Next.js, Fastify, Express

If you need these, open an issue on GitHub.

Getting Started

  1. Install: npm install @gavincettolo/envault
  2. Create src/env.ts with a check() call
  3. Import env from that file everywhere
  4. Optionally add generateExample() to a prebuild script

envault moves env validation to startup, catches all failures at once, and keeps secrets out of logs.