Env validator
Env is deploy-time configuration, not runtime state. The validator runs once at boot, lists every problem it finds, freezes the result, and exposes a typed env object to the rest of the app.
Two principles drive the design: no silent fallbacks in production (a missing or malformed var fails the boot with a readable error listing every problem), and one place to read process.env. Direct process.env.FOO outside the validator is a lint error.
How validation works
Section titled “How validation works”flowchart LR
raw["readRaw()<br/>process.env + coercion"] --> shape{TypeBox<br/>schema valid?}
shape -- no --> err1["throw with every<br/>shape error listed"]
shape -- yes --> inv{cross-field<br/>invariants pass?}
inv -- no --> err2["throw with every<br/>invariant error listed"]
inv -- yes --> freeze["Object.freeze(env)"]
freeze --> ready[("env exported")]
The two-pass split matters: running invariants on an already-shape-validated object means errors read “STRIPE_SECRET_KEY required when BILLING_ENABLED=true”, not “property STRIPE_SECRET_KEY should be string”.
Design
Section titled “Design”TypeBox handles field shape validation (the same library Elysia uses; no extra validation DSL to learn). Predicates handle cross-field rules: “if A then B” stays readable as hand-written checks. All errors aggregate, so one failed boot lists every missing var instead of one redeploy per problem. The frozen config object at runtime reads like env.isProduction, not repeated NODE_ENV string checks. Empty integration keys are allowed when a feature is off; shape passes while invariants enforce keys when enabled. Test fallbacks are explicit via nonEmpty(value, testFallback) and never leak to production.
Shape vs. invariant
Section titled “Shape vs. invariant”A shape rule expresses “this field must be a positive int between 1 and 65535.” TypeBox does that:
PORT: t.Integer({ minimum: 1, maximum: 65535, default: 7330 }),PUBLIC_API_URL: t.String({ minLength: 1 }),JWT_SECRET: t.String({ minLength: 32 }),EMAIL_PROVIDER: t.Union([t.Literal("cloudflare"), t.Literal("resend"), t.Literal("sendgrid"), t.Literal("smtp")]),An invariant rule expresses “if A is true, B must be set.” TypeBox can’t say that cleanly. A predicate can:
if (env.BILLING_ENABLED && env.STRIPE_SECRET_KEY === "") { errors.push("STRIPE_SECRET_KEY required when BILLING_ENABLED=true");}Predicates each return string[] and fan into one aggregated check, so every problem surfaces in one boot attempt.
Invariant rules
Section titled “Invariant rules”- CORS: Non-empty ALLOWED_ORIGINS entries must be https and wildcard-free.
- Email: Production requires the matching email-provider credentials.
- URLs: FRONTEND_URL, PUBLIC_API_URL, and notification settings URLs must be valid http(s) URLs.
- OAuth: Google, GitHub, and LinkedIn credentials must be supplied as client-id/client-secret pairs.
- AI: AI_ENABLED=true requires the matching OpenAI or Anthropic key.
- Stripe: BILLING_ENABLED=true requires Stripe secret, webhook secret, and price IDs.
- Valkey: Queues, cache, notification SSE, or OAuth require VALKEY_PASSWORD in production.
NODE_ENV=test skips most of these so integration tests don’t need real provider credentials.
When boot fails, every problem is listed at once:
JWT_SECRET: Expected string length greater or equal to 32STRIPE_SECRET_KEY required when BILLING_ENABLED=trueSTRIPE_WEBHOOK_SECRET required when BILLING_ENABLED=trueSTRIPE_PRICE_ID_FREE required when BILLING_ENABLED=trueGoogle OAuth requires both client id and client secretSeveral problems, one redeploy to fix all of them.
Adding a new env var
Section titled “Adding a new env var”- Add the field to the TypeBox schema with the right type + default.
- Add it to
readRaw()with a parser helper (toInt,toBool,toCsv,nonEmpty,toFloat). - If it has a cross-field rule, write a
check*predicate and add it tocheckInvariants(). - Document it in
.env.example(and incompose/.env.exampleif it flows through the prod profile). - Use
env.MY_VAReverywhere. Don’t touchprocess.envdirectly; the lint plugin will catch it.
Lint contract
Section titled “Lint contract”@boring-stack-pkg/eslint-plugin-env-access enforces the validator:
process.env.Xis only allowed insidesrc/config/env/.- The matching rule applies to
import.meta.envon the UI side.
Without this rule, code eventually gets written like const x = process.env.FEATURE_FLAG ?? "default" deep in a handler; undocumented, untyped, unvalidated. The lint catches it on first try.
Source
Section titled “Source”src/config/env/; schema, validator, parsers. .env.example is the per-var reference with comments.
Related
Section titled “Related”- Authentication, Email, Queues; per-feature env requirements.
- Environment variables; cross-repo index.
- Lint as the contract.