close

DEV Community

Cover image for How to Validate Environment Variables Without a Library (And Why You Should Anyway)
Odejobi Abiola Samuel
Odejobi Abiola Samuel

Posted on

How to Validate Environment Variables Without a Library (And Why You Should Anyway)

How to Validate Environment Variables Without a Library (And Why You Should Anyway)

I'm a fan of minimal dependencies. Every library you add is code you don't control, surface area for bugs, and another thing to keep up to date. So when I started a new project last month, I told myself: no env validation library. I'll write it myself. It's just a few vars.

Six hours later I had a surprisingly solid little validation function. Let me show you what I built.

Part 1: Your Own Env Validation in Plain TypeScript

Here's the complete thing, about 60 lines:

type EnvSchema = Record<string, {
  type: "string" | "number" | "boolean"
  required?: boolean
  default?: unknown
}>

function loadEnv<T extends EnvSchema>(
  schema: T,
  source: Record<string, string | undefined> = process.env
): { [K in keyof T]: T[K]["type"] extends "number" ? number
     : T[K]["type"] extends "boolean" ? boolean
     : string } {

  const result: Record<string, unknown> = {}

  for (const [key, config] of Object.entries(schema)) {
    let raw = source[key]

    if (raw === undefined) {
      if (config.default !== undefined) {
        raw = String(config.default)
      } else if (config.required !== false) {
        throw new Error(`Missing required env var: ${key}`)
      } else {
        continue
      }
    }

    switch (config.type) {
      case "number": {
        const num = Number(raw)
        if (Number.isNaN(num)) {
          throw new Error(`${key} must be a number, got "${raw}"`)
        }
        result[key] = num
        break
      }
      case "boolean": {
        const truthy = ["true", "yes", "1", "on"]
        const falsy = ["false", "no", "0", "off"]
        if (truthy.includes(raw.toLowerCase())) {
          result[key] = true
        } else if (falsy.includes(raw.toLowerCase())) {
          result[key] = false
        } else {
          throw new Error(`${key} must be a boolean, got "${raw}"`)
        }
        break
      }
      default:
        result[key] = raw
    }
  }

  return result as any
}
Enter fullscreen mode Exit fullscreen mode

Usage:

const env = loadEnv({
  PORT: { type: "number", default: 3000 },
  DATABASE_URL: { type: "string", required: true },
  DEBUG: { type: "boolean", default: false },
})

env.PORT // number
Enter fullscreen mode Exit fullscreen mode

That works. It's typed. It validates at startup. I was pretty proud of this for about a day.

Part 2: Why You Might Want a Library Anyway

Then the project grew. Here's what happened:

Your validation function grows. You start adding refinements: "PORT must be between 1024 and 65535", "DATABASE_URL must start with postgres://". Now your schema config has extra fields and your validation loop has special cases.

Error messages are inconsistent. I wrote decent errors, but what about when someone else touches the file? Every team member writes messages differently. Some are helpful. Some say "invalid". Good luck debugging that in CI.

No CLI tooling. Want to validate .env.production before a deploy? You're running a script that imports your config module. You can't just point a CLI at an env file and check it against your schema.

No documentation generation. I kept a ENVIRONMENT.md file updated for about three days. Then it was stale forever. Nobody wants to manually document env vars.

No framework adapters. Vite uses import.meta.env. Next.js inlines vars at build time. If your config module assumes process.env, it doesn't work everywhere. You end up maintaining adapters yourself.

No secret masking. One accidental console.log(config) later, your entire team has new API keys to rotate.

Part 3: What CtroEnv Does Differently

I'm not here to tell you CtroEnv is the only answer. But since I built it, let me show you what I mean by "a library handles this."

Same validation from Part 1:

import { defineEnv, string, number, boolean } from "@ctroenv/core"

const env = defineEnv({
  PORT: number().port().default(3000),
  DATABASE_URL: string().url(),
  DEBUG: boolean().default(false),
})
Enter fullscreen mode Exit fullscreen mode

Same type safety. Fewer lines. But the real difference isn't in the code — it's everything around it.

CLI validation: npx ctroenv validate --source .env.production — checks your env against the schema and exits with a non-zero code on failure. Drop it in your CI pipeline.

Generated docs: npx ctroenv docs produces an ENVIRONMENT.md file that's always accurate because it's generated from the schema.

Secret masking: Add .secret() to any variable and it's hidden from logs, console output, and JSON.stringify.

Framework adapters: One schema, but it works with process.env, import.meta.env, and Next.js's build-time inlining without changing your code.

Consistent errors: Every validation failure follows the same format, with error codes you can check programmatically.

So Should You Use a Library?

If you have 3 env vars in a personal project, write your own function. You'll learn something and you won't need the extras.

If you have a team, a CI pipeline, staging and production deploys, and more than 5 env vars — use a library. The validation logic is the easy part. It's the tooling, the docs, the edge cases, and the framework support that'll eat your time.


Links: GitHub | npm | Docs

Top comments (3)

Collapse
 
nazar_boyko profile image
Nazar Boyko

There's a small gap between what that return type promises and what loadEnv actually delivers. When a var has required: false and no default, the loop hits continue and never sets the key, but the conditional return type still types it as a present string. So env.OPTIONAL_THING reads as string at compile time while being undefined at runtime, which is exactly the class of bug the whole post argues you should catch at startup. Threading whether a var can be missing through the type (something like adding undefined to that branch when required extends false) closes it. Solid write-up otherwise, the "six hours later" arc is very real.

Collapse
 
ctrotech profile image
Odejobi Abiola Samuel

Good catch brother, you're actually right about the Part 1 example. That helper can end up returning undefined without the type reflecting it. Just to clarify though, that snippet was the DIY example from the article, not the implementation behind CtroEnv. In CtroEnv, optional values are typed explicitly (".optional()" → "T | undefined", ".default()" stays non-nullable). This was actually one of the things I ran into while rolling my own and part of why I ended up building the library.
Appreciate you pointing it out.

Collapse
 
frank_signorini profile image
Frank

How does your 50-line TypeScript setup handle nested environment variables? I'm following your work for more insights on env validation. Would love to hear about your approach to handling edge cases.