{"slug": "i-built-a-zero-dependency-env-validator-with-full-typescript-inference-here-s", "title": "I Built a Zero-Dependency Env Validator with Full TypeScript Inference - Here's Why", "summary": "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.", "body_md": "Every Node.js project I've ever worked on has the same invisible vulnerability.\n\nSomewhere in the codebase, there's a line like this:\n\n``` js\nconst db = new Client({ url: process.env.DATABASE_URL })\n```\n\nIt 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.\n\nThe app starts. No error. No warning. Just `undefined`\n\nquietly 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.\n\nI got tired of this. So I built **envault**.\n\n`envault`\n\nvalidates, coerces, and types your environment variables at startup, and reports every failing variable at once, not one at a time.`.env.example`\n\n.`array`\n\ntype, `requiredIn`\n\nfor per-environment enforcement, and `validate`\n\nfor custom logic.\n\n```\nprocess.env.DATABASE_URL // type: string | undefined\n```\n\nThat's it. That's the problem.\n\nTypeScript knows your env variable might be `undefined`\n\n. It tells you every time you use it. And most developers either suppress the warning with a non-null assertion (`!`\n\n) or check it inline in every file that needs it, neither of which actually validates the value at startup.\n\nYou don't find out `DATABASE_URL`\n\nis broken when the app starts. You find out when the first request hits the database.\n\n`envault`\n\nmoves 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.\n\n``` js\nimport { check } from '@gavincettolo/envault'\n\nconst env = check({\n  DATABASE_URL: { type: 'url',    required: true },\n  PORT:         { type: 'number', default: 3000  },\n  NODE_ENV:     { type: 'enum',   values: ['development', 'production', 'test'] as const },\n  API_KEY:      { type: 'string', required: true, secret: true },\n})\n```\n\n`check()`\n\ndoes three things in one call:\n\n`\"3000\"`\n\n→ `3000`\n\n, `\"true\"`\n\n→ `true`\n\n).`env.PORT`\n\nis `number`\n\n, not `string | undefined`\n\n).If anything fails, the app throws immediately with every broken variable listed at once:\n\n```\nenvault: 2 environment variables failed validation:\n\n  ✗ Missing required variable \"DATABASE_URL\"\n  ✗ \"PORT\" must be a number (got \"not-a-port\")\n```\n\nNo more fixing one variable, redeploying, finding the next.\n\n```\nnpm install @gavincettolo/envault\n# or\npnpm add @gavincettolo/envault\n```\n\nCreate a dedicated `env.ts`\n\nfile at the root of your project, one source of truth for all environment variables:\n\n``` js\n// src/env.ts\nimport { check } from '@gavincettolo/envault'\n\nexport const env = check({\n  DATABASE_URL: { type: 'url',     required: true },\n  PORT:         { type: 'number',  default: 3000  },\n  NODE_ENV:     { type: 'enum',    values: ['development', 'production', 'test'] as const, default: 'development' },\n  JWT_SECRET:   { type: 'string',  required: true, secret: true },\n  DEBUG:        { type: 'boolean', default: false },\n})\n```\n\nImport `env`\n\nfrom this file anywhere in your codebase. TypeScript knows exactly what each field is, no casting, no non-null assertions, no surprises.\n\n``` js\n// src/server.ts\nimport { env } from './env'\n\napp.listen(env.PORT, () => {\n  // env.PORT is number, not string | undefined\n  console.log(`Server running on port ${env.PORT}`)\n})\n```\n\nSensitive variables marked with `secret: true`\n\nnever appear in error output, not even when validation fails:\n\n``` js\nconst env = check({\n  STRIPE_SECRET_KEY: { type: 'string', required: true, secret: true },\n})\n// If missing: \"Missing required variable STRIPE_SECRET_KEY\"\n// The value is never logged, not even a partial one\n```\n\nThis 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.\n\n`.env.example`\n\nSchemas and example files drift apart the moment someone adds a new variable and forgets to update the docs. `generateExample()`\n\nproduces the file directly from the same schema `check()`\n\nuses:\n\n``` js\nimport { generateExample } from '@gavincettolo/envault'\nimport { writeFileSync } from 'node:fs'\nimport { schema } from './src/env.js'\n\nwriteFileSync('.env.example', generateExample(schema))\n```\n\nThe output includes types, constraints, and descriptions as comments, so your `.env.example`\n\nis always accurate, because it can't drift from the schema:\n\n```\n# PostgreSQL connection string\n# required, type: url\nDATABASE_URL=https://example.com\n\n# HTTP server port\n# type: number\nPORT=3000\n\n# required, secret, type: string\nAPI_KEY=\njs\nconst env = check({\n  WORKERS:  { type: 'number', default: 4, min: 1, max: 32 },\n  SLUG:     { type: 'string', required: true, pattern: /^[a-z0-9-]+$/ },\n})\n```\n\nBy default, `check()`\n\nthrows. You can override this for structured logging:\n\n``` js\ncheck(schema, {\n  onError(errors) {\n    for (const e of errors) logger.fatal(e)\n    process.exit(1)\n  },\n})\n```\n\nFor tests, override `process.env`\n\nentirely:\n\n``` js\nconst env = check(schema, {\n  env: { PORT: '8080', DATABASE_URL: 'https://db.example.com' },\n})\n```\n\nThis 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.\n\nThe schema is a discriminated union keyed on `type`\n\n:\n\n```\ntype FieldDef =\n  | { type: 'string';  required?: boolean; default?: string;  secret?: boolean; pattern?: RegExp }\n  | { type: 'number';  required?: boolean; default?: number;  min?: number; max?: number }\n  | { type: 'boolean'; required?: boolean; default?: boolean }\n  | { type: 'url';     required?: boolean; default?: string;  secret?: boolean }\n  | { type: 'enum';    values: readonly string[]; required?: boolean; default?: string }\n  | { type: 'json';    required?: boolean; default?: unknown }\n  | { type: 'array';   required?: boolean; default?: string[]; delimiter?: string; secret?: boolean }\n```\n\nA conditional type maps each field definition to its output type:\n\n```\ntype Infer<F extends FieldDef> =\n  F['type'] extends 'string'  ? string  :\n  F['type'] extends 'number'  ? number  :\n  F['type'] extends 'boolean' ? boolean :\n  F['type'] extends 'array'   ? string[] :\n  F extends { type: 'enum'; values: infer V }\n    ? V extends readonly string[] ? V[number] : string\n    : never\n```\n\nThe `enum`\n\ncase is where it gets interesting. If you pass `values: ['dev', 'prod'] as const`\n\n, TypeScript captures the literal tuple type, and `V[number]`\n\ncollapses it into `'dev' | 'prod'`\n\n, not a loose `string`\n\n. Without `as const`\n\n, you lose that precision. It's a sharp edge worth knowing about, and it's documented clearly in the README.\n\nOptionality follows the same pattern, a field is non-nullable if it's `required: true`\n\nor has a `default`\n\n, otherwise it's `T | undefined`\n\n:\n\n```\ntype Env<S extends Schema> = {\n  readonly [K in keyof S]: IsRequired<S[K]> extends true\n    ? Infer<S[K]>\n    : Infer<S[K]> | undefined\n}\n```\n\nThe result: `check()`\n\nis generic over the schema shape. TypeScript captures it at the call site. No casting, no `as`\n\n, no manual type declarations duplicating what's already in the schema.\n\nThe honest answer: you can, and it works well.\n\nBut it's two dependencies, around twenty lines of glue code, and a schema that lives separately from your `.env.example`\n\n, which means it will eventually drift.\n\nHere's how envault compares:\n\n| envault | dotenv + zod | envalid | |\n|---|---|---|---|\n| Zero dependencies | ✓ | ✗ | ✗ |\n| Full TS inference | ✓ | ✓ | partial |\n| Aggregated errors | ✓ | ✓ | ✓ |\n| Secret masking in errors | ✓ | ✗ | ✗ |\n`.env.example` generator |\n✓ | ✗ | ✗ |\n| ESM + CJS dual build | ✓ | ✓ | ✓ |\n\nThe trade-off is scope. `envault`\n\ndoesn'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`\n\nis a legitimate choice.\n\nIf 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.\n\nThree additions that came out of real usage patterns.\n\nA 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:\n\n``` js\nconst env = check({\n  ALLOWED_ORIGINS: { type: 'array', required: true },\n  TAGS:            { type: 'array', default: [], delimiter: '|' },\n})\n\n// ALLOWED_ORIGINS=a.com, b.com, c.com → ['a.com', 'b.com', 'c.com']\n// TAGS=frontend|backend|infra          → ['frontend', 'backend', 'infra']\n```\n\nThe default delimiter is `,`\n\nwith automatic whitespace trimming. Override it with `delimiter`\n\nfor any other separator.\n\n`requiredIn`\n\n- per-environment enforcement\nSome variables are mandatory in production but optional locally. `requiredIn`\n\nlets you express that directly in the schema:\n\n``` js\nconst env = check(\n  {\n    DATABASE_URL:  { type: 'url',    requiredIn: ['production', 'staging'] },\n    SENTRY_DSN:    { type: 'url',    requiredIn: ['production'] },\n    REDIS_URL:     { type: 'url',    requiredIn: ['production', 'staging'] },\n  },\n  { environment: process.env.NODE_ENV },\n)\n```\n\nIn 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.\n\n`validate`\n\n- custom validation logic\nFor checks that go beyond what the built-in options cover, every field type now accepts a `validate`\n\nfunction. Return `true`\n\nto pass, or a string to fail with a specific message:\n\n``` js\nconst env = check({\n  PORT: {\n    type: 'number',\n    required: true,\n    validate: (n) => n > 1024 ? true : 'must be a non-privileged port (> 1024)',\n  },\n  DATABASE_URL: {\n    type: 'url',\n    required: true,\n    validate: (url) => url.startsWith('postgres://') ? true : 'must be a PostgreSQL connection string',\n  },\n})\n```\n\nThe error message you return becomes part of the aggregated error output, same format, same clarity.\n\nA few things I'm actively thinking about:\n\n`generateExample()`\n\nas a CLI command`npx envault generate`\n\nin CI without a script file.`.env`\n\nfile changes in development.`src/env.ts`\n\npattern with `generateExample`\n\nin a `prebuild`\n\nscript.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.\n\n**Zero-dependency environment variable validation with full TypeScript inference.**\n\n`process.env.DATABASE_URL`\n\nis silently `undefined`\n\nat runtime, and nobody finds out until the app crashes in production. `envault`\n\nvalidates, coerces, and types your environment variables at startup — and reports every problem at once.\n\n``` js\nimport { check } from 'envault'\n\nconst env = check({\n  DATABASE_URL: { type: 'url',     required: true },\n  PORT:         { type: 'number',  default: 3000  },\n  NODE_ENV:     { type: 'enum',    values: ['development', 'production', 'test'] as const },\n  API_KEY:      { type: 'string',  required: true, secret: true },\n})\n\n// env.PORT         → number\n// env.DATABASE_URL → string\n// env.NODE_ENV     → 'development' | 'production' | 'test' | undefined\n// env.API_KEY      → string\n```\n\nIf validation fails, you get a clear, aggregated error — not a…\n\n**What's your current setup for environment variable validation?**\n\nStill using raw `process.env`\n\n? 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.\n\nIf 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.", "url": "https://wpnews.pro/news/i-built-a-zero-dependency-env-validator-with-full-typescript-inference-here-s", "canonical_source": "https://dev.to/gavincettolo/i-built-a-zero-dependency-env-validator-with-full-typescript-inference-heres-why-2f4n", "published_at": "2026-07-07 07:30:00+00:00", "updated_at": "2026-07-07 07:58:24.477786+00:00", "lang": "en", "topics": ["developer-tools", "large-language-models"], "entities": ["Gavin Cettolo", "envault", "Node.js", "TypeScript"], "alternates": {"html": "https://wpnews.pro/news/i-built-a-zero-dependency-env-validator-with-full-typescript-inference-here-s", "markdown": "https://wpnews.pro/news/i-built-a-zero-dependency-env-validator-with-full-typescript-inference-here-s.md", "text": "https://wpnews.pro/news/i-built-a-zero-dependency-env-validator-with-full-typescript-inference-here-s.txt", "jsonld": "https://wpnews.pro/news/i-built-a-zero-dependency-env-validator-with-full-typescript-inference-here-s.jsonld"}}