DEV Community

Cover image for I Built a Zero-Dependency Env Validator with Full TypeScript Inference - Here's Why
Gavin Cettolo
Gavin Cettolo

Posted on

I Built a Zero-Dependency Env Validator with Full TypeScript Inference - Here's Why

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 })
Enter fullscreen mode Exit fullscreen mode

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.


TL;DR

  • envault validates, coerces, and types your environment variables at startup, and reports every failing variable at once, not one at a time.
  • Zero runtime dependencies. Full TypeScript inference. Secret masking. Auto-generated .env.example.
  • New in v0.2.0: array type, requiredIn for per-environment enforcement, and validate for custom logic.

Table of Contents


The Problem in One Line

process.env.DATABASE_URL // type: string | undefined
Enter fullscreen mode Exit fullscreen mode

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.


What envault Does

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 },
})
Enter fullscreen mode Exit fullscreen mode

check() does three things in one call:

  1. Validates every variable against its schema (type, format, constraints).
  2. Coerces raw strings into the correct type ("3000"3000, "true"true).
  3. Returns a fully typed object (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")
Enter fullscreen mode Exit fullscreen mode

No more fixing one variable, redeploying, finding the next.


Getting Started in 30 Seconds

npm install @gavincettolo/envault
# or
pnpm add @gavincettolo/envault
Enter fullscreen mode Exit fullscreen mode

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 },
})
Enter fullscreen mode Exit fullscreen mode

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}`)
})
Enter fullscreen mode Exit fullscreen mode

The Features That Actually Solve Real Problems

Secret masking

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
Enter fullscreen mode Exit fullscreen mode

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.

Auto-generated .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))
Enter fullscreen mode Exit fullscreen mode

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=
Enter fullscreen mode Exit fullscreen mode

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-]+$/ },
})
Enter fullscreen mode Exit fullscreen mode

Custom error handler and test-friendly env source

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)
  },
})
Enter fullscreen mode Exit fullscreen mode

For tests, override process.env entirely:

const env = check(schema, {
  env: { PORT: '8080', DATABASE_URL: 'https://db.example.com' },
})
Enter fullscreen mode Exit fullscreen mode

How the TypeScript Inference Works

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.


Why Not dotenv + Zod?

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.


What's New in v0.2.0

Three additions that came out of real usage patterns.

Array type, comma-separated lists

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']
Enter fullscreen mode Exit fullscreen mode

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 },
)
Enter fullscreen mode Exit fullscreen mode

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',
  },
})
Enter fullscreen mode Exit fullscreen mode

The error message you return becomes part of the aggregated error output, same format, same clarity.


What's Next

A few things I'm actively thinking about:

  • generateExample() as a CLI command: so you can run npx envault generate in CI without a script file.
  • Watch mode: reload and re-validate when the .env file changes in development.
  • Integration examples: a reference setup for Next.js, Fastify, and Express showing the recommended 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.

GitHub logo gavincettolo / envault

Zero-dependency environment variable validation with full TypeScript inference

envault

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (2)

Collapse
 
elenchen profile image
Elen Chen

We had a production incident last year caused by a missing env variable in a staging-to-prod deploy. It took ~45 minutes to trace because everything looked correct at first glance. Something like this would have failed fast and loudly at startup instead of silently breaking runtime behavior. That alone is a huge win.

Collapse
 
frank_signorini profile image
Frank

I'm curious how you handled type inference