shape
TypeBox for field shape
Same library Elysia uses; no extra validation DSL to learn.
Env validation
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.
TypeBox
shape validation
freeze
runtime config object
lint
no direct process.env
Two principles drive the design:
process.env. Direct process.env.FOO outside the validator is a lint error.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 the error reads “STRIPE_SECRET_KEY required when BILLING_ENABLED=true”, not “property STRIPE_SECRET_KEY should be string”.
shape
Same library Elysia uses; no extra validation DSL to learn.
logic
If A then B rules stay readable as hand-written checks.
errors
One failed boot lists every missing var instead of one redeploy per problem.
runtime
Reads like env.isProduction, not repeated NODE_ENV string checks.
optional
Shape passes while invariants enforce keys when a feature is enabled.
tests
A few vars get nonEmpty(value, testFallback), never in production.
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: 3000 }),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.
Non-empty ALLOWED_ORIGINS entries must be https and wildcard-free.
Production requires the matching email-provider credentials.
FRONTEND_URL, PUBLIC_API_URL, and notification settings URLs must be valid http(s) URLs.
Google, GitHub, and LinkedIn credentials must be supplied as client-id/client-secret pairs.
AI_ENABLED=true requires the matching OpenAI or Anthropic key.
BILLING_ENABLED=true requires Stripe secret, webhook secret, and price IDs.
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.
$ bun run dev
! JWT_SECRET: Expected string length greater or equal to 32
! STRIPE_SECRET_KEY required when BILLING_ENABLED=true
! STRIPE_WEBHOOK_SECRET required when BILLING_ENABLED=true
! STRIPE_PRICE_ID_FREE required when BILLING_ENABLED=true
! Google OAuth requires both client id and client secretSeveral problems, one redeploy to fix all of them.
readRaw() with a parser helper (toInt, toBool, toCsv, nonEmpty, toFloat).check* predicate and add it to checkInvariants()..env.example (and in compose/.env.example if it flows through the prod profile).env.MY_VAR everywhere. Don’t touch process.env directly; the lint plugin will catch it.@boring-stack-pkg/eslint-plugin-env-access is what makes the validator load-bearing:
process.env.X is only allowed inside src/config/env/.import.meta.env on the UI side.Without this rule, somebody eventually writes const x = process.env.FEATURE_FLAG ?? "default" deep in a handler; undocumented, untyped, unvalidated. The lint catches it on first try.
src/config/env/; schema, validator, parsers. .env.example is the per-var reference with comments.