DEV Community

Cover image for Stop Writing Environment Variable Validation From Scratch
Husain Korasawala
Husain Korasawala

Posted on

Stop Writing Environment Variable Validation From Scratch

Every project I've worked on has this code somewhere.

const PORT = process.env.PORT
const DATABASE_URL = process.env.DATABASE_URL
const API_KEY = process.env.API_KEY

if (!DATABASE_URL) throw new Error('DATABASE_URL is required')
if (!API_KEY) throw new Error('API_KEY is required')

const port = parseInt(PORT ?? '3000')
Enter fullscreen mode Exit fullscreen mode

It's not terrible. But it's not great either. It's scattered across files, it gives you string | undefined everywhere, and when something is wrong you get a cryptic error 10 layers deep in your app instead of a clear message at startup.

So you clean it up a bit. Maybe you centralize it. Maybe you add some type assertions. Maybe you reach for envalid or t3-env. And then you discover:

  • envalid has no TypeScript inference — everything comes back as a loose type
  • t3-env is great but requires Zod, and not every project uses Zod
  • Neither works cleanly across Node, Vite, Deno, and Cloudflare Workers without config gymnastics

So you end up solving it from scratch. Again. Every. Single. Project.

That's why I built env-validated.


What it does

env-validated validates your environment variables at startup, throws a single clear error listing every problem, and returns a fully typed config object. You never touch process.env directly again.

import { createEnv } from 'env-validated'

export const env = createEnv({
  schema: {
    API_URL:        { type: 'url',     required: true },
    PORT:           { type: 'number',  default: 3000 },
    NODE_ENV:       { type: 'enum',    values: ['development', 'production', 'test'] as const },
    ENABLE_FEATURE: { type: 'boolean', default: false },
    SECRET_KEY:     { type: 'string',  required: true, minLength: 32 },
  }
})

// Fully typed — no casting needed
env.PORT           // number
env.ENABLE_FEATURE // boolean
env.NODE_ENV       // 'development' | 'production' | 'test'
Enter fullscreen mode Exit fullscreen mode

If anything is wrong, you get this at startup — before your app does anything:

[env-validated] Missing or invalid environment variables:
  ✖ API_URL     — required, was not set
  ✖ SECRET_KEY  — must be at least 32 characters (got 12)
  ✖ NODE_ENV    — must be one of: development, production, test (got "staging")

Fix these before starting the app.
Enter fullscreen mode Exit fullscreen mode

All errors collected at once. No hunting through stack traces at 2am.


The part I'm most excited about: pluggable validators

Here's where env-validated is different from everything else out there.

A lot of teams already use a validation library — Zod, Joi, Yup, whatever. They shouldn't have to learn a new schema syntax just to validate env vars. So env-validated auto-detects which library a field belongs to and routes it through the right adapter automatically.

No adapter imports. No configuration. Just pass your schema directly:

import { createEnv } from 'env-validated'
import { z } from 'zod'

const env = createEnv({
  schema: {
    PORT: z.coerce.number().min(1000),
    TAGS: z.string().transform(s => s.split(',')),
    ENV:  z.enum(['dev', 'prod', 'test']),
  }
})

env.PORT // number
env.TAGS // string[] — transforms work too
env.ENV  // 'dev' | 'prod' | 'test'
Enter fullscreen mode Exit fullscreen mode

Same thing with Joi:

import Joi from 'joi'

const env = createEnv({
  schema: {
    PORT: Joi.number().min(1000).required(),
    NAME: Joi.string().min(3).required(),
  }
})
Enter fullscreen mode Exit fullscreen mode

Yup, Valibot, TypeBox, ArkType, Superstruct, Runtypes, Effect Schema — all supported, all auto-detected. 9 adapters total, with zero dependencies in the core package.

And here's the thing that I think is genuinely unique: you can mix them in the same schema.

import { z } from 'zod'
import * as v from 'valibot'

const env = createEnv({
  schema: {
    PORT:    z.coerce.number().min(1000),    // Zod
    REGION:  { type: 'enum', values: ['us', 'eu'] as const }, // built-in
    NAME:    v.pipe(v.string(), v.minLength(3)), // Valibot
  }
})
Enter fullscreen mode Exit fullscreen mode

Each field is validated independently. Types are inferred correctly across all of them.


Already have a schema defined? Pass it directly.

If you already have a z.object() or yup.object() defined in your codebase, you don't need to rewrite it field by field. Pass it straight to createEnv:

import { z } from 'zod'

const envSchema = z.object({
  HOST:  z.string(),
  PORT:  z.coerce.number(),
  DEBUG: z.coerce.boolean(),
})

const env = createEnv({ schema: envSchema })

env.HOST  // string
env.PORT  // number
env.DEBUG // boolean
Enter fullscreen mode Exit fullscreen mode

Works the same way with Yup, Valibot, TypeBox, ArkType, Superstruct, Runtypes, and Effect Schema.


Works anywhere

The second argument to createEnv is an options object. The most useful option is source — it lets you pass any plain object as the env source, which is what makes env-validated work across every runtime:

// Vite
createEnv({ schema: { ... } }, { source: import.meta.env, prefix: 'VITE_' })

// Deno
createEnv({ schema: { ... } }, { source: Deno.env.toObject() })

// Cloudflare Workers
createEnv({ schema: { ... } }, { source: cfEnv as Record<string, string> })

// Testing — full isolation, no process.env leaking in
createEnv({ schema: { ... } }, { source: { PORT: '3000', NODE_ENV: 'test' } })
Enter fullscreen mode Exit fullscreen mode

The prefix option strips framework prefixes automatically, so you define your schema as API_URL instead of VITE_API_URL or NEXT_PUBLIC_API_URL.


Secrets are never exposed in error output

Variables whose names end in _KEY, _SECRET, _TOKEN, _PASSWORD, or _PASS are automatically masked in error messages:

[env-validated] Missing or invalid environment variables:
  ✖ API_KEY     — must be at least 32 characters (got *****)
  ✖ DB_PASSWORD — required, was not set
Enter fullscreen mode Exit fullscreen mode

The values are still validated normally — they're just never printed.


Bring your own validation logic

For fields that don't fit any library, you can pass a plain validate function. The return type is inferred automatically:

const env = createEnv({
  schema: {
    ALLOWED_IPS: {
      validate: (val) => {
        const ips = (val ?? '').split(',').map(s => s.trim())
        const valid = ips.every(ip => /^\d{1,3}(\.\d{1,3}){3}$/.test(ip))
        return valid
          ? { success: true as const,  value: ips }
          : { success: false as const, error: 'Must be a comma-separated list of IPs' }
      }
    }
  }
})

env.ALLOWED_IPS // string[]
Enter fullscreen mode Exit fullscreen mode

Install

npm install env-validated
Enter fullscreen mode Exit fullscreen mode

Zero dependencies. Works with Node.js, Vite, Next.js, Remix, NestJS, Deno, Cloudflare Workers, and any CLI script.


If you've ever copy-pasted env validation code between projects, give it a try. And if you use a validator library that isn't supported yet, the adapter contract is public — you can register your own or publish a community adapter.

Would love to hear what you think. 🙏

Top comments (0)