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
}
Usage:
const env = loadEnv({
PORT: { type: "number", default: 3000 },
DATABASE_URL: { type: "string", required: true },
DEBUG: { type: "boolean", default: false },
})
env.PORT // number
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),
})
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.













