Skip to content

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.

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 ms
const maxUpload = env.bytes('MAX_UPLOAD', '8mb') // → 8388608
const 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 otherwise
env.isProduction // nodeEnv === 'production'
env.isDevelopment
env.isTest

Anything 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 default
env.string('AUTH_SECRET', 'dev-secret') // ✗ ships a secret every deployment shares
env.string('AUTH_SECRET') // ✓ refuses to start without one

A 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.

Because JavaScript’s coercions are quietly wrong in exactly the ways environment variables are quietly wrong:

Boolean('false') // true — DEBUG=false turns debugging ON
Number('') // 0 — an unset PORT becomes port 0
parseInt('12abc') // 12 — a typo'd value is silently truncated

None 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.

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.

src/env.ts
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 // number

A 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.

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:

src/env.ts
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.