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 (1)
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.