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")
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
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:
- Reads each variable from
process.env(or any source you give it) - Runs it through the validator
- Applies defaults for missing optional vars
- 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"
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
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
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
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(),
},
})
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" })],
})
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}`)
},
})
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)
}
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)
Quick question on the secret masking, because I like the idea but can't quite picture the use site. If
env.JWT_SECRETreturns********on direct access, what happens when I actually need the real value to sign a token? Do I have to reach forenv.meta.get('JWT_SECRET')at every spot the secret is really used, and if someone forgets and writesjwt.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.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:
You get the real value, same as accessing
process.env.JWT_SECRETdirectly.The common pattern is to extract secrets into named constants right after the env object is created:
This way you only have one
.meta.get()call per secret, and the rest of your code uses a plainstringvariable, 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 likeenv.secrets.JWT_SECRETthat skips the Proxy for this exact reason. Haven't settled on the API yet, but the concern is valid.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.
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 itsparseEnvFile()function. It interpolates$VARand${VAR}references from the same.envfile or fromprocess.env.If you mean grouping vars by prefix (e.g.,
DB_HOST,DB_PORT,DB_USER→ a single config object), ctroenv gives youprefixondefineEnv():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:Then
JSON.parse(env.DATABASE_CONFIG)gives you your nested object, and the schema still validates it at startup.