Skip to content
BoringStack
GitHub

Env validator

5 min read

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:

  • No silent fallbacks in production. A missing or malformed var fails the boot, with a readable error listing every problem, not just the first.
  • One place to read 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

TypeBox for field shape

Same library Elysia uses; no extra validation DSL to learn.

logic

Predicates for cross-field rules

If A then B rules stay readable as hand-written checks.

errors

All errors aggregate

One failed boot lists every missing var instead of one redeploy per problem.

runtime

Frozen derived env

Reads like env.isProduction, not repeated NODE_ENV string checks.

optional

Empty integration keys are allowed when off

Shape passes while invariants enforce keys when a feature is enabled.

tests

Test fallbacks are explicit

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.

cors

Production origins

Non-empty ALLOWED_ORIGINS entries must be https and wildcard-free.

email

Provider keys

Production requires the matching email-provider credentials.

urls

Public URLs

FRONTEND_URL, PUBLIC_API_URL, and notification settings URLs must be valid http(s) URLs.

oauth

OAuth pairs

Google, GitHub, and LinkedIn credentials must be supplied as client-id/client-secret pairs.

ai

AI provider keys

AI_ENABLED=true requires the matching OpenAI or Anthropic key.

stripe

Billing keys

BILLING_ENABLED=true requires Stripe secret, webhook secret, and price IDs.

valkey

Production password

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.

Bad boot output
$ 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 secret

Several problems, one redeploy to fix all of them.

  1. Add the field to the TypeBox schema with the right type + default.
  2. Add it to readRaw() with a parser helper (toInt, toBool, toCsv, nonEmpty, toFloat).
  3. If it has a cross-field rule, write a check* predicate and add it to checkInvariants().
  4. Document it in .env.example (and in compose/.env.example if it flows through the prod profile).
  5. Use 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/.
  • The matching rule applies to 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.