Environment variables
Two tools for two jobs. env.* reads one variable with a sensible parse; defineEnv validates
your whole configuration against a schema. Reaching for a schema to read one optional flag is
overkill, and reading forty variables one at a time is worse.
Reading one variable
Section titled “Reading one variable”import { env } from '@theoven/core'
const port = env.port('PORT', 3000)const debug = env.bool('DEBUG', false)const origins = env.list('ALLOWED_ORIGINS', [])const timeout = env.duration('REQUEST_TIMEOUT', '30s') // → 30000 msconst maxUpload = env.bytes('MAX_UPLOAD', '8mb') // → 8388608const level = env.oneOf('LOG_LEVEL', ['debug', 'info', 'warn', 'error'], 'info')const database = env.url('DATABASE_URL') // throws when unset| Method | Reads |
|---|---|
string(name, fallback?) |
a trimmed, non-empty string |
optional(name) |
a string or undefined — never throws |
raw(name) |
the value untouched |
number / int |
a number; int rejects fractions rather than rounding |
bool |
true/false, 1/0, yes/no, y/n, on/off |
port |
an integer in 1–65535 |
url |
a value that actually parses as a URL |
list |
comma-separated, trimmed, empties dropped |
oneOf(name, values, fallback?) |
one of a fixed set |
duration |
500ms, 30s, 5m, 1h, 2d, or bare milliseconds |
bytes |
512kb, 8mb, 1gb, or bare bytes (binary units) |
has(name) |
whether it is set to something non-empty |
all() |
every variable, with secret values redacted |
Plus four getters, not methods — no parentheses:
env.nodeEnv // 'development' unless NODE_ENV says otherwiseenv.isProduction // nodeEnv === 'production'env.isDevelopmentenv.isTestAnything absent with no fallback, or present but unparseable, throws an error naming the variable.
Fallbacks are for defaults, not for secrets
Section titled “Fallbacks are for defaults, not for secrets”const port = env.port('PORT', 3000) // ✓ a sensible defaultenv.string('AUTH_SECRET', 'dev-secret') // ✗ ships a secret every deployment sharesenv.string('AUTH_SECRET') // ✓ refuses to start without oneA fallback turns a missing variable into a silent default. That is right for a port and wrong
for anything that authenticates — which is why basicAuth takes secret with no default and
fails at boot rather than inventing one.
Why not just read process.env
Section titled “Why not just read process.env”Because JavaScript’s coercions are quietly wrong in exactly the ways environment variables are quietly wrong:
Boolean('false') // true — DEBUG=false turns debugging ONNumber('') // 0 — an unset PORT becomes port 0parseInt('12abc') // 12 — a typo'd value is silently truncatedNone of those produce an error. They produce a service that starts, looks healthy, and behaves
wrongly. env.bool('DEBUG') on DEBUG=flase throws instead, naming the variable.
Secrets are redacted
Section titled “Secrets are redacted”env.all()// { PORT: '3000', DATABASE_URL: '[redacted]', STRIPE_SECRET_KEY: '[redacted]' }Names matching SECRET, TOKEN, KEY, PASSWORD, CREDENTIAL, PRIVATE, SALT, DSN,
DATABASE_URL and similar are redacted in both all() and error messages:
DATABASE_URL: expected a valid URL, got [redacted]PORT: expected a number, got "12abc"The variable is named either way — that is the useful half. The value only appears when it is safe to show. The reason anyone dumps an environment is to put it somewhere readable later: a log, an error report, a support ticket. Redaction is the default for that reason.
Matching is deliberately loose. A false positive redacts something harmless; a false negative puts a production database password into a log aggregator, and there is no taking it back.
Validating everything at boot
Section titled “Validating everything at boot”import { defineEnv } from '@theoven/core'import { z } from 'zod'
export const env = defineEnv( z.object({ DATABASE_URL: z.url(), PORT: z.coerce.number().default(3000), STRIPE_KEY: z.string().startsWith('sk_'), }),)
env.PORT // numberA missing DATABASE_URL otherwise surfaces forty minutes into production as
connect ECONNREFUSED undefined:undefined, from a stack trace pointing at a connection pool
rather than at the config. This refuses to start instead:
EnvError: Invalid environment: DATABASE_URL: Invalid input: expected string, received undefined STRIPE_KEY: Invalid input: must start with "sk_"Every problem at once — fixing environment configuration one variable per restart is a genuinely awful way to spend twenty minutes.
Printing it cleanly
Section titled “Printing it cleanly”Left to propagate, an EnvError is rendered by Bun like any other uncaught throw: a stack with
source context from inside the library, and the line naming your variable somewhere below it.
The scaffold therefore catches it in src/env.ts and prints the message on its own:
try { loaded = read()} catch (error) { if (error instanceof EnvError) { console.error(`\n${error.message}\n`) process.exit(1) } throw error}Invalid environment: LOG_LEVEL: expected one of debug, info, warn, error, got "verbose"A configuration mistake should read like one.
Any Standard Schema validator works here, same as route validation.