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 (4)

Collapse
 
nazar-boyko profile image
Nazar Boyko

Quick question on the secret masking, because I like the idea but can't quite picture the use site. If env.JWT_SECRET returns ******** on direct access, what happens when I actually need the real value to sign a token? Do I have to reach for env.meta.get('JWT_SECRET') at every spot the secret is really used, and if someone forgets and writes jwt.sign(payload, env.JWT_SECRET), do they quietly sign with the literal asterisks? That's the one case that would scare me, since it fails silently instead of loudly. Curious how the Proxy draws the line between reading a value for logs and reading it to actually use.

Collapse
 
ctrotech profile image
Odejobi Abiola Samuel

You're right to be wary , that would be a nasty failure mode. Here's how it actually works:
The Proxy only masks the value at the top level. If you write:

const secret = env.meta.get("JWT_SECRET")
jwt.sign(payload, secret)
Enter fullscreen mode Exit fullscreen mode

You get the real value, same as accessing process.env.JWT_SECRET directly.
The common pattern is to extract secrets into named constants right after the env object is created:

const env = defineEnv({
  JWT_SECRET: string().min(32).secret(),
  DATABASE_URL: string().url(),
})
const JWT_SECRET = env.meta.get("JWT_SECRET")!
Enter fullscreen mode Exit fullscreen mode

This way you only have one .meta.get() call per secret, and the rest of your code uses a plain string variable, no asterisk landmines, no accidental leaks.
The trade-off is intentional: the Proxy optimizes for the common dangerous case (a stray console.log(env) in a request handler dumping everything), not for convenience. You can't accidentally leak secrets through a logging statement, but you also can't accidentally sign with asterisks if you follow the extract-once pattern.

That said, I've been thinking about adding a raw() method or a second export like env.secrets.JWT_SECRET that skips the Proxy for this exact reason. Haven't settled on the API yet, but the concern is valid.

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.

Collapse
 
ctrotech profile image
Odejobi Abiola Samuel

Reply to nested env vars question:

Short answer: env vars are flat strings by definition, there's no nested structure in the environment itself. So the library doesn't try to invent one.

What you can do depends on what "nested" means to you:

If you mean vars that reference other vars, like DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@host/db — the Node adapter handles that with its parseEnvFile() function. It interpolates $VAR and ${VAR} references from the same .env file or from process.env.

If you mean grouping vars by prefix (e.g., DB_HOST, DB_PORT, DB_USER → a single config object), ctroenv gives you prefix on defineEnv():

const env = defineEnv(
  { HOST: string(), PORT: number().port() },
  { source: objectSource({ DB_HOST: "localhost", DB_PORT: "5432" }), prefix: "DB_" },
)
// env.HOST → "localhost", env.PORT → 5432
Enter fullscreen mode Exit fullscreen mode

If you mean actual nested objects like { database: { host: "..." } } — I left that out on purpose. Env vars aren't JSON, and pretending they are causes more confusion than it solves. If you need that shape, it's usually cleaner to parse a single JSON env var:

const env = defineEnv({
  DATABASE_CONFIG: string().validate((v) => {
    try { JSON.parse(v); return undefined } catch { return "must be valid JSON" }
  }),
})
Enter fullscreen mode Exit fullscreen mode

Then JSON.parse(env.DATABASE_CONFIG) gives you your nested object, and the schema still validates it at startup.