DEV Community

Cover image for Environment Variables Don't Have to Suck
Odejobi Abiola Samuel
Odejobi Abiola Samuel

Posted on • Originally published at github.com

Environment Variables Don't Have to Suck

I've lost count of how many times I've seen this in production:

const port = parseInt(process.env.PORT || "3000", 10)
const dbUrl = process.env.DATABASE_URL
if (!dbUrl) throw new Error("DATABASE_URL is required")
Enter fullscreen mode Exit fullscreen mode

It's in every codebase. The same six lines, copy-pasted across a dozen files. Half the time someone forgets parseInt exists and the port becomes the string "3000". The other half the error message is misleading because someone renamed the variable in the schema but forgot to update the check.

I got tired of fixing these bugs and built something small.

The core idea

One function. A typed schema. Zero dependencies.

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

const env = defineEnv({
  DATABASE_URL: string().url(),
  PORT: number().port().default(3000),
})

// env.DATABASE_URL is `string`
// env.PORT is `number` — coerced from the env var string
Enter fullscreen mode Exit fullscreen mode

That's it. You describe what you expect, and you get a typed, frozen object back. If the actual environment doesn't match, you get a clear error instead of a cryptic undefined downstream.

What it actually does at runtime

When you call defineEnv(), it:

  1. Reads each variable from process.env (or any source you give it)
  2. Runs it through the validator
  3. Applies defaults for missing optional vars
  4. Freezes the result object

No hidden file reads. No global state. No runtime dependencies.

Where this saves real time

1. No more startup crashes with bad types

const env = defineEnv({
  REDIS_PORT: number().port(),
})
// If REDIS_PORT is "abc" or "99999", this throws immediately
// with: "REDIS_PORT: Expected a port number (1-65535), received 99999"
Enter fullscreen mode Exit fullscreen mode

Instead of your app starting, connecting to Redis, and then failing with some obscure connection error 30 seconds later, you get the failure at import time.

2. Boolean coercion that actually makes sense

const env = defineEnv({
  DEBUG: boolean(),
})
// Coerces common boolean strings: "true", "1", "yes", "on" → true
Enter fullscreen mode Exit fullscreen mode

No more comparing strings to "true" manually.

3. Secrets that don't leak in logs

const env = defineEnv({
  JWT_SECRET: string().min(32).secret(),
})

console.log(env.JWT_SECRET) // "********"
console.log(env.meta.get("JWT_SECRET")) // actual value
Enter fullscreen mode Exit fullscreen mode

The Proxy wrapper masks secret values on direct access. You have to explicitly use .meta.get() to read them. This means a stray console.log(env) in a request handler won't dump your secrets.

The CLI is worth mentioning

I use this more than the library itself some days:

ctroenv validate          # Check env against your schema
ctroenv check --strict    # CI gate — exits non-zero on mismatch
ctroenv generate          # Create .env.example from schema
Enter fullscreen mode Exit fullscreen mode

The check command is what runs in CI. It diffs the .env file against the schema and exits 1 if anything's missing or wrong. The --warn-unknown flag also catches typos in your .env file (like DATABASE_URLL with a suggestion for DATABASE_URL).

How it handles real-world mess

Next.js (server/client split)

import { defineEnv } from "@ctroenv/nextjs"

export const env = defineEnv({
  server: {
    DATABASE_URL: string().url(),
    JWT_SECRET: string().secret(),
  },
  client: {
    NEXT_PUBLIC_API_URL: string().url(),
  },
})
Enter fullscreen mode Exit fullscreen mode

Server-only variables throw if accessed on the client. The types are merged so you get one typed object.

Vite plugin

import { ctroenvPlugin } from "@ctroenv/vite"

export default defineConfig({
  plugins: [ctroenvPlugin({ schema: "./src/env.ts" })],
})
Enter fullscreen mode Exit fullscreen mode

Validates env at build time. If you forgot to set VITE_API_URL, the build fails before you deploy.

Watching for changes

import { watchEnv } from "@ctroenv/core"

const env = watchEnv(schema, {
  pollInterval: 200,
  onChange(key, oldVal, newVal) {
    console.log(`${key} changed: ${oldVal}${newVal}`)
  },
})
Enter fullscreen mode Exit fullscreen mode

Useful for local dev servers that need to react to .env file changes without a restart.

What I left out

I deliberately kept validators minimal. No hex() or base64(). The philosophy is: ship the 80% case, and let people write custom validators for the rest.

import { createValidator, applyChain, parseOk, singleError, errType, errInvalid } from "@ctroenv/core"

function hexColor() {
  const base = createValidator<string>(
    (input, ctx) => {
      if (typeof input !== "string")
        return singleError(errType(ctx.key, typeof input, "hex color"))
      if (!/^#[0-9a-fA-F]{3,6}$/.test(input))
        return singleError(errInvalid(ctx.key, input, "not a valid hex color"))
      return parseOk(input)
    },
    { typeLabel: "hexcolor" },
  )
  return applyChain(base)
}
Enter fullscreen mode Exit fullscreen mode

Sixteen lines. No base class. No decorator magic.

The numbers

  • Core package: zero dependencies, ~4 KB gzipped
  • 480 tests, 98% line coverage
  • Works in Node 18+, Bun, Deno, Cloudflare Workers, Vite, Next.js

You can find it at github.com/ctrotech-tutor/ctroenv.


I wrote this library because I was tired of debugging "cannot read properties of undefined" that traced back to a missing env var. If you've been there too, give it a try — or don't. Either way, stop writing manual process.env checks.

Top comments (1)

Collapse
 
frank_signorini profile image
Frank

I've struggled with process.env being string-typed, how does your library handle nested env vars? I'd love to hear more about your approach.