cd /news/developer-tools/i-built-a-zero-dependency-env-valida… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-49086] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=↑ positive

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.

read9 min views1 publishedJul 7, 2026

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.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @gavin cettolo 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/i-built-a-zero-depen…] indexed:0 read:9min 2026-07-07 Β· β€”