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

> Source: <https://dev.to/gavincettolo/i-built-a-zero-dependency-env-validator-with-full-typescript-inference-heres-why-2f4n>
> Published: 2026-07-07 07:30:00+00:00

Every Node.js project I've ever worked on has the same invisible vulnerability.

Somewhere in the codebase, there's a line like this:

``` js
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.

``` js
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
# or
pnpm add @gavincettolo/envault
```

Create a dedicated `env.ts`

file at the root of your project, one source of truth for all environment variables:

``` js
// 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.

``` js
// 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:

``` js
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:

``` js
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:

```
# PostgreSQL connection string
# required, type: url
DATABASE_URL=https://example.com

# HTTP server port
# type: number
PORT=3000

# required, secret, type: string
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:

``` js
check(schema, {
  onError(errors) {
    for (const e of errors) logger.fatal(e)
    process.exit(1)
  },
})
```

For tests, override `process.env`

entirely:

``` js
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:

``` js
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:

``` js
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:

``` js
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 command`npx 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.

``` js
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.
