manas-envguard
v0.2.0
Published
Lightweight, TypeScript-first environment variable validation with zero dependencies.
Maintainers
Readme
envguard
Lightweight, TypeScript-first environment variable validation.
- Tiny — zero runtime dependencies, fully tree-shakeable
- Type-safe — schema → typed env object, no
as stringcasts - Universal — Node.js, Bun, Deno, Cloudflare Workers, Vercel Edge, browsers
- Predictable — explicit chaining, immutable validators, structured errors
- Friendly — every issue surfaces with
key,expected,received,validator,message
Install
npm install envguard
# or: pnpm add envguard / bun add envguard / yarn add envguardQuick start
import { validateEnv, string, number, boolean, enumType } from "envguard";
const env = validateEnv({
PORT: number().int().default(3000),
DATABASE_URL: string().url(),
NODE_ENV: enumType(["development", "production", "test"]),
DEBUG: boolean().optional(),
});
// env is fully typed:
// PORT: number
// DATABASE_URL: string
// NODE_ENV: "development" | "production" | "test"
// DEBUG: boolean | undefinedIf validation fails, validateEnv throws an EnvValidationError with every issue aggregated — you fix your config in one round-trip, not one variable at a time.
EnvValidationError: Environment validation failed (2 issues):
• PORT: expected number, got "abc"
• DATABASE_URL: expected a valid URLAPI
Validators
| Factory | Description |
| ------------------- | ------------------------------------------------------------------- |
| string() | string, with .min .max .regex .url .email .uuid |
| number() | number, with .min .max .int .positive |
| boolean() | accepts true/false/1/0/yes/no/on/off (any case) |
| enumType(values) | string literal union from a tuple of strings |
| port() | integer in [1, 65535] — shorthand for number().int().min(1).max(65535) |
| json<T>() | JSON.parse an env value (pair with .refine for shape checks) |
| array(element?) | split by , — bare for string[], or pass a validator for typed elements |
| custom(fn) | escape hatch — any (raw: string) => T |
Every validator supports:
.optional()— absent value becomesundefined.default(value)— fallback when absent.refine(check, message)— extra predicate after parsing.transform(fn)— map the parsed value to a new type
Chains are immutable — each method returns a new validator.
const apiKey = string().min(32).max(64).refine(
(v) => !v.includes(" "),
"API_KEY must not contain spaces"
);
// Compose: validate a URL, then hand back a parsed URL object.
const dbUrl = string().url().transform((u) => new URL(u));
// Typed arrays.
const corsOrigins = array(string().url()); // URL[]
const ports = array(number().int().min(1)); // number[]validateEnv(schema, options?)
| Option | Type | Description |
| ------------- | ---- | ----------- |
| env | Record<string, string \| undefined> | Source. Defaults to the detected runtime env. |
| safe | boolean | Return { success, data, errors } instead of throwing. |
| strict | boolean | Error on keys present in env but missing from the schema. |
| maskSecrets | boolean \| RegExp \| (key) => boolean | Redact received for secret-looking keys. Defaults to true. |
| onError | (issues) => void | Custom formatter — runs before throw/return. |
Secret masking
By default, any issue whose key matches /secret|token|password|api[_-]?key|auth|credential|private[_-]?key|dsn/i will have its received value replaced with ***. This stops error.message, console.error(issue), and any logging pipeline from accidentally leaking credentials. Override with maskSecrets: false, a custom RegExp, or a predicate.
Safe parsing
const result = safeParseEnv(schema, { env: process.env });
if (!result.success) {
for (const issue of result.errors) console.error(issue);
process.exit(1);
}
const env = result.data;Custom error formatting
Use the structured issues array directly:
validateEnv(schema, {
onError(issues) {
for (const i of issues) {
console.error(`× ${i.key} — expected ${i.expected}, got ${JSON.stringify(i.received)}`);
}
},
});Or use the built-in pretty printer (auto-colors when stdout is a TTY, respects NO_COLOR):
import { safeParseEnv, EnvValidationError } from "envguard";
const result = safeParseEnv(schema);
if (!result.success) {
console.error(new EnvValidationError(result.errors).prettyPrint());
process.exit(1);
}ValidationIssue shape
{
key: "PORT",
expected: "number",
received: "abc",
validator: "number",
code: "invalid_type", // missing_required | invalid_type | invalid_format
// | invalid_enum | constraint_violation
// | unknown_key | custom
message: "expected number, got \"abc\""
}Schema composition
Schemas are plain objects. Compose with spread:
const db = { DATABASE_URL: string().url() };
const api = { API_KEY: string().min(32) };
const env = validateEnv({ ...db, ...api });dotenv
The dotenv loader is an opt-in import — your edge bundle stays clean.
import { loadDotenv } from "envguard";
import { validateEnv, string } from "envguard";
await loadDotenv(".env", process.env);
const env = validateEnv({ DATABASE_URL: string().url() });Custom validators
import { custom } from "envguard";
const csv = custom<string[]>(
(raw) => raw.split(",").map((s) => s.trim()),
"csv"
);
// Or return a structured result:
const port = custom<number>((raw) => {
const n = Number(raw);
return Number.isFinite(n) && n > 0
? { ok: true, value: n }
: { ok: false, message: "must be a positive number" };
});Runtime support
envguard's core uses no Node-only APIs.
| Runtime | Status |
| -------------------- | ------ |
| Node.js 16+ | ✅ |
| Bun | ✅ |
| Deno | ✅ (needs --allow-env) |
| Cloudflare Workers | ✅ (pass env arg via options.env) |
| Vercel Edge | ✅ |
| Browser | ✅ (pass an env object via options.env) |
The loadDotenv helper is the only Node/Bun/Deno-only export and tree-shakes out of edge bundles when unused.
Benchmarks
Run locally with npm run bench. Numbers vary by machine — sample run on a Windows 11 / Node 22 laptop:
validateEnv (7 fields) ~870 k ops/s
validateEnv (single field) ~2.6 M ops/s
schema construction ~570 k ops/sCross-library bundle and perf comparisons (vs. envalid / valibot / zod) are tracked in issue #1 — happy to take PRs that add reproducible numbers.
Migrating from envalid
// envalid
import { cleanEnv, str, num, port } from "envalid";
const env = cleanEnv(process.env, {
PORT: port({ default: 3000 }),
DATABASE_URL: str(),
});
// envguard
import { validateEnv, number, string } from "envguard";
const env = validateEnv({
PORT: number().int().min(1).max(65535).default(3000),
DATABASE_URL: string().url(),
});Migrating from Zod (env-only usage)
// zod
const env = z
.object({
PORT: z.coerce.number().int().default(3000),
DATABASE_URL: z.string().url(),
})
.parse(process.env);
// envguard
const env = validateEnv({
PORT: number().int().default(3000),
DATABASE_URL: string().url(),
});Troubleshooting
"My optional var is undefined but 'KEY' in env is false."
That's intentional — envguard never sets undefined keys, so in checks behave the way most consumers expect.
"Boolean parsing rejected my value."
Accepted forms are case-insensitive: true/false/1/0/yes/no/y/n/on/off. Anything else fails. Use a custom validator if you need different spellings.
"enumType widened my type to string."
Pass the values tuple with as const, or with a generic argument:
enumType(["dev", "prod"] as const)
enumType<"dev" | "prod">(["dev", "prod"])"My number rejected "1e10"."
It doesn't — Number("1e10") is finite, so it parses. "Infinity", "NaN", and "abc" all fail by design.
Publishing
Tag the release; the publish workflow handles the rest.
npm version patch # or minor / major
git push --follow-tagsProvenance is enabled (publishConfig.provenance: true) — releases are signed via npm's package provenance.
License
MIT
