Environment Configuration API
Package: @onebun/envs
Overview
OneBun provides type-safe environment configuration with validation, default values, and sensitive data handling.
Env Helper
Type Definitions
// String variable
Env.string(options?: StringEnvOptions)
// Number variable
Env.number(options?: NumberEnvOptions)
// Boolean variable
Env.boolean(options?: BooleanEnvOptions)
// Array variable
Env.array(options?: ArrayEnvOptions)Options Interface
interface EnvVariableConfig<T = unknown> {
/** Environment variable name (defaults to uppercase path) */
env?: string;
/** Type of the variable */
type: 'string' | 'number' | 'boolean' | 'array';
/** Default value if not set — also applied when the variable is set to an empty string */
default?: T;
/**
* Whether the variable is required. Must be set explicitly: with neither `default` nor
* `required` the variable takes the type's zero value instead of failing — see
* [Neither `default` nor `required`](#neither-default-nor-required).
*/
required?: boolean;
/**
* Mark as sensitive: `get()` returns a wrapper whose `toString()`/`toJSON()` is `'***'`,
* and `getSafeConfig()` masks it. Validation errors never print any value, sensitive or not.
*/
sensitive?: boolean;
/**
* Validation function. It returns an **Effect**, not a boolean: `Effect.succeed(value)` to
* accept, `Effect.fail(new EnvValidationError(...))` to reject. A boolean is not a valid
* Effect and fails every variable it is attached to, including the ones that use the default.
*/
validate?: (value: T) => Effect.Effect<T, EnvValidationError>;
/** Separator for array values (default: ',') */
separator?: string;
/** Description for documentation */
description?: string;
}Env.number() additionally accepts min/max, and Env.array() accepts minLength/maxLength. Those are helper options that build the validate function for you — they are not fields of EnvVariableConfig. There is no transform option; see Deriving values.
Defining Schema
// src/config.ts
import { Env, type InferConfigType } from '@onebun/core';
// Define schema using Env helpers
export const envSchema = {
// Nested structure becomes dotted paths
server: {
port: Env.number({ default: 3000, env: 'PORT' }),
host: Env.string({ default: '0.0.0.0', env: 'HOST' }),
},
database: {
url: Env.string({
env: 'DATABASE_URL',
required: true,
sensitive: true, // Masked in logs
}),
maxConnections: Env.number({
default: 10,
env: 'DB_MAX_CONNECTIONS',
}),
ssl: Env.boolean({
default: true,
env: 'DB_SSL',
}),
},
redis: {
host: Env.string({ default: 'localhost', env: 'REDIS_HOST' }),
port: Env.number({ default: 6379, env: 'REDIS_PORT' }),
password: Env.string({
env: 'REDIS_PASSWORD',
sensitive: true,
required: false,
}),
},
features: {
enableCache: Env.boolean({ default: true }),
allowedOrigins: Env.array({
default: ['http://localhost:3000'],
env: 'ALLOWED_ORIGINS',
separator: ',',
}),
},
app: {
name: Env.string({ default: 'my-app' }),
version: Env.string({ default: '1.0.0' }),
debug: Env.boolean({ default: false, env: 'DEBUG' }),
},
};
// Infer config type automatically from schema
export type AppConfig = InferConfigType<typeof envSchema>;
// Result: { server: { port: number; host: string }; database: { url: string; ... }; ... }
// Module augmentation for global type inference
declare module '@onebun/core' {
interface OneBunAppConfig extends AppConfig {}
}Type Inference and Module Augmentation
OneBun provides automatic type inference for configuration access using TypeScript's module augmentation feature.
InferConfigType
The InferConfigType utility automatically extracts value types from your schema:
import { Env, type InferConfigType } from '@onebun/core';
const envSchema = {
server: {
port: Env.number({ default: 3000 }),
host: Env.string({ default: '0.0.0.0' }),
},
database: {
url: Env.string({ required: true }),
},
};
type Config = InferConfigType<typeof envSchema>;
// Result: { server: { port: number; host: string }; database: { url: string } }Module Augmentation
To enable typed config access throughout your application (in controllers, services, etc.), use TypeScript's module augmentation:
// config.ts
import { Env, type InferConfigType } from '@onebun/core';
export const envSchema = {
server: {
port: Env.number({ default: 3000 }),
},
};
export type AppConfig = InferConfigType<typeof envSchema>;
// This enables typed access to this.config.get() everywhere
declare module '@onebun/core' {
interface OneBunAppConfig extends AppConfig {}
}After this setup, this.config.get('server.port') in any controller or service will return number (not unknown).
Without Module Augmentation
If you don't use module augmentation, you can still access config but need type assertions:
// Works but requires manual typing
const port = this.config.get('server.port') as number;Pre-init Config Access
Use getConfig() to access configuration values synchronously before application bootstrap — for example, to configure cors, rateLimit, or queue adapters in ApplicationOptions.
import { OneBunApplication, getConfig } from '@onebun/core';
import { AppModule } from './app.module';
import { envSchema, type AppConfig } from './config';
// Synchronous — reads .env via readFileSync, no await needed
const config = getConfig<AppConfig>(envSchema);
const app = new OneBunApplication(AppModule, {
envSchema,
cors: { origin: config.get('server.corsOrigin') },
rateLimit: { windowMs: config.get('rateLimit.windowMs'), max: config.get('rateLimit.max') },
});getConfig() returns the same interface as this.config in services — with .get(), .values, .getSafeConfig(), and full type inference via module augmentation.
Results are cached per schema reference — calling getConfig() multiple times with the same schema object returns the same instance.
Loading Configuration
In Application
import { OneBunApplication } from '@onebun/core';
import { AppModule } from './app.module';
import { envSchema } from './config';
const app = new OneBunApplication(AppModule, {
envSchema,
envOptions: {
// Path to .env file (relative to cwd or absolute)
envFilePath: '.env',
// Load .env file (default: true)
loadDotEnv: true,
// Process.env overrides .env file (default: true)
envOverridesDotEnv: true,
// Default separator for arrays (default: ',')
defaultArraySeparator: ',',
// Override specific values using env variable names (takes precedence)
// Note: Use actual env variable names, not dot-notation paths
valueOverrides: {
PORT: 4000, // Overrides server.port (env: 'PORT')
DEBUG: true, // Overrides app.debug (env: 'DEBUG')
},
},
});Standalone Usage
import { TypedEnv } from '@onebun/envs';
const config = TypedEnv.create(envSchema, { envFilePath: '.env', loadDotEnv: true }, 'standalone');
// Must initialize before use
await config.initialize();
// Now safe to access
const port = config.get('server.port');TypedEnv.create is process-global and cached by key
The full signature is TypedEnv.create<T>(schema, options?, key = 'default'). When an instance already exists for key, it is returned as-is and both schema and options are ignored — no error, no warning.
OneBunApplication creates its own configuration under that same default key, and whichever call runs first claims the slot — a standalone TypedEnv.create(envSchema, options) at module scope runs before the application is constructed. Measured: a standalone call followed by an application configured with valueOverrides: { PORT: 4000 } starts with port still 3000, the override silently discarded. Passing a distinct key, as above, gives the application its own instance and the override applies.
TypedEnv.clear() drops every cached instance; call it between tests.
This is not the same rule as getConfig(), which caches per schema reference (see Pre-init Config Access) — distinct schemas there get distinct instances.
Accessing Configuration
With module augmentation in place, config access is fully typed - no as any needed!
In Controllers
@Controller('/info')
export class InfoController extends BaseController {
@Get('/')
async getInfo() {
// Fully typed access - no casting needed
const serverPort = this.config.get('server.port'); // number
const appName = this.config.get('app.name'); // string
const debug = this.config.get('app.debug'); // boolean
return {
appName,
serverPort,
debug,
};
}
}In Services
@Service()
export class DatabaseService extends BaseService {
async connect(): Promise<void> {
// Fully typed access
const url = this.config.get('database.url'); // string (sensitive)
const maxConnections = this.config.get('database.maxConnections'); // number
const ssl = this.config.get('database.ssl'); // boolean
this.logger.info('Connecting to database', { maxConnections, ssl });
// url.value for sensitive values
await this.client.connect(url.value);
}
}From Application
With module augmentation, both getConfig() and getConfigValue() provide full type inference:
// In config.ts - define module augmentation for type safety
declare module '@onebun/core' {
interface OneBunAppConfig {
server: { port: number; host: string };
database: { url: string; maxConnections: number };
}
}
// In index.ts
const app = new OneBunApplication(AppModule, { envSchema });
await app.start();
// Get config service - returns IConfig<OneBunAppConfig>
const config = app.getConfig();
// Typed access with autocomplete - no manual type annotation needed!
const port = config.get('server.port'); // number (auto-inferred)
const host = config.get('server.host'); // string (auto-inferred)
const dbUrl = config.get('database.url'); // string (auto-inferred)
// Convenience method - also fully typed
const maxConns = app.getConfigValue('database.maxConnections'); // number (auto-inferred)
// Get all values - typed as OneBunAppConfig
const values = config.values;
// Get safe config (sensitive values masked) - typed as OneBunAppConfig
const safeConfig = config.getSafeConfig();
console.log(safeConfig);
// { server: { port: 3000, host: 'localhost' }, database: { url: '***', ... } }Sensitive Values
Values marked as sensitive: true are automatically wrapped:
const envSchema = {
database: {
password: Env.string({ sensitive: true }),
},
};
// Access the value (fully typed with module augmentation)
const password = this.config.get('database.password');
// password.toString() returns '***'
// password.value returns actual value
// Safe for logging
this.logger.info('Config', { password }); // Logs '***'
// Get actual value
const actualPassword = password.value;sensitive: true covers value access — get() returns the wrapper above, and getSafeConfig() replaces the value with '***'. It does not need to cover validation errors, because those never carry a value in the first place — see below.
Rejected Values Are Never Echoed
No validation error ever contains the value that was rejected — for any variable, whether or not it is marked sensitive. There is no flag to switch this on and nothing to remember.
The reason is timing: env validation fails during startup, and the framework logs that failure itself (Failed to start application:) before your code gets control. There is no point at which a user could suppress it, so an error that echoes values would ship secrets to the log aggregator on every failed boot.
EnvValidationError therefore describes the value instead of printing it, and never stores it:
const config = {
database: {
password: Env.number({ env: 'DATABASE_PASSWORD', required: true, sensitive: true }),
},
};
// With DATABASE_PASSWORD=super-secret-p@ssw0rd the startup error reads:
// EnvValidationError: Environment variable validation failed for "DATABASE_PASSWORD":
// Value is not a valid number. Got: a string of length 21The message still names the variable and the reason, so the misconfiguration is fixable without ever seeing the secret. The description is one of:
| Value | Got: |
|---|---|
| unset | not set |
'' | an empty string |
'hunter2' | a string of length 7 |
42 | a number |
true | a boolean |
['a', 'b'] | an array of length 2 |
{ … } | an object |
The length is deliberate: it is what reveals a stray quote or a trailing space without revealing the value. The same description is what error.value holds — the raw value is not kept on the error instance either, so a structured logger that serializes error properties cannot leak it.
WARNING
The trade-off is that a rejected value is not visible anywhere in the logs, including for non-secret variables. To see what a variable actually holds, use config.getSafeConfig() after a successful boot, or inspect the environment directly.
Empty Values
VAR= means "not configured". An environment variable set to an empty string is treated exactly like a variable that was never set: the declared default applies, and required: true is not satisfied.
This is the shape real deployments produce — a docker-compose env_file with a blank value, a Kubernetes ConfigMap key with no value, a CI variable that was declared but never populated.
const envSchema = {
database: {
host: Env.string({ env: 'DB_HOST', default: 'localhost' }),
port: Env.number({ env: 'DB_PORT', default: 5432 }),
url: Env.string({ env: 'DATABASE_URL', required: true }),
},
};
// .env
// DB_HOST=
// DB_PORT=
// DATABASE_URL=
config.get('database.host'); // 'localhost' — the default, not ''
config.get('database.port'); // 5432 — the default, not a parse error
// DATABASE_URL throws: it is required and an empty string does not satisfy that.The startup error says which of the two happened, so an operator whose compose file blanked a value can tell it apart from one nobody ever declared:
Environment variable validation failed for "DATABASE_URL": Required variable is not set. Got: not set
Environment variable validation failed for "DATABASE_URL": Required variable is set to an empty string. Got: an empty stringWhat counts as empty
Only the exact empty string. Whitespace is a value the operator typed and is kept as-is:
| Raw value | Env.string({ default: 'localhost' }) | Env.number({ default: 5432 }) |
|---|---|---|
| unset | 'localhost' | 5432 |
VAR= | 'localhost' | 5432 |
VAR=" " | ' ' | throws — not a valid number |
Neither default nor required
A variable with neither is never absent — it takes the type's zero value: '', 0, false, []. This keeps InferConfigType honest (server.host is string, never string | undefined), but it means the zero value cannot be distinguished from a real one.
Env.string() // unset -> '' (indistinguishable from VAR=)
Env.string({ default: 'localhost' }) // unset -> 'localhost'
Env.string({ required: true }) // unset -> throws at startupIf your code needs to tell "not configured" apart from "configured to the zero value", declare a default or required: true.
Environment Variable Naming
By default, nested paths are converted to uppercase with underscores:
| Schema Path | Environment Variable |
|---|---|
server.port | SERVER_PORT |
database.url | DATABASE_URL |
redis.host | REDIS_HOST |
Override with env option:
const envSchema = {
server: {
port: Env.number({
env: 'PORT', // Uses PORT instead of SERVER_PORT
}),
},
};Array Variables
const envSchema = {
allowedHosts: Env.array({
default: ['localhost'],
env: 'ALLOWED_HOSTS',
separator: ',', // Custom separator
}),
};
// .env
// ALLOWED_HOSTS=example.com,api.example.com,localhost
// Result
config.get('allowedHosts');
// ['example.com', 'api.example.com', 'localhost']Startup Validation
Environment variables are validated at application startup during the TypedEnv.create() / initialize() phase. If any validation fails, the application throws an EnvValidationError and does not start.
When Validation Runs
OneBunApplicationconstructor callsTypedEnv.create(envSchema, options)TypedEnv.create()iterates over the schema and parses each variable- For each variable: load value → parse type → run custom validation
- If any step fails, an
EnvValidationErroris thrown immediately
Validation Failures
Missing required variable (no default, required: true or implicit):
const envSchema = {
database: {
url: Env.string({ env: 'DATABASE_URL', required: true }),
},
};
// If DATABASE_URL is not set in environment or .env file:
// Throws: EnvValidationError: Environment variable validation failed for "DATABASE_URL":
// Required variable is not set. Got: not set
// If DATABASE_URL is present but blank (`DATABASE_URL=`):
// Throws: EnvValidationError: Environment variable validation failed for "DATABASE_URL":
// Required variable is set to an empty string. Got: an empty stringCustom validation function failure:
import { Effect } from 'effect';
import { EnvValidationError } from '@onebun/envs';
const envSchema = {
server: {
port: Env.number({
default: 3000,
// validate returns Effect.Effect<T, EnvValidationError>
validate: (value) =>
value > 0 && value < 65536
? Effect.succeed(value)
: Effect.fail(
new EnvValidationError('SERVER_PORT', value, 'Port must be between 1 and 65535'),
),
}),
},
};
// If SERVER_PORT=99999:
// Throws: EnvValidationError: Environment variable validation failed for "SERVER_PORT":
// Port must be between 1 and 65535. Got: a number
//
// The value is described, never printed — see "Rejected Values Are Never Echoed".The first argument is the variable name and it is yours to pass: the error you construct is rethrown as-is, so new EnvValidationError('', …) produces ... failed for "": and leaves the operator with nothing to grep for.
TIP
For common range validation, use min/max options instead of a custom validate function:
Env.number({ default: 3000, min: 1, max: 65535 })Or use built-in validators like Env.port():
Env.number({ default: 3000, validate: Env.port() })Both reject the value with the right reason, but neither knows the variable it was attached to, so today they report it without a name — Environment variable validation failed for "": Value must be <= 65535. Got: a number. Where the operator needs the name, write the validate function above and pass the name yourself.
Catching Startup Errors
import { OneBunApplication, EnvValidationError } from '@onebun/core';
import { AppModule } from './app.module';
import { envSchema } from './config';
try {
const app = new OneBunApplication(AppModule, { envSchema });
await app.start();
} catch (error) {
if (error instanceof EnvValidationError) {
console.error(`Configuration error: ${error.message}`);
console.error(`Variable: ${error.variable}`);
// A description such as 'a string of length 21' — never the value itself
console.error(`Value: ${error.value}`);
process.exit(1);
}
throw error;
}strict does nothing
EnvLoadOptions.strict is accepted by the types and has no implementation — nothing reads it. Setting it neither restricts loading to schema variables nor makes anything required; measured, a schema parsed with strict: true and with strict: false produces byte-identical values, and a missing variable with no required flag still yields the type's zero value under both. Do not reach for it as production hardening.
What actually raises EnvValidationError is required: true on the variable:
const envSchema = {
database: {
url: Env.string({ env: 'DATABASE_URL', required: true }),
},
};Validation
Built-in Validation
The declarative options cover the common cases and need no Effect import:
const envSchema = {
server: {
port: Env.number({ default: 3000, min: 1, max: 65535 }), // or: validate: Env.port()
},
app: {
logLevel: Env.string({
env: 'LOG_LEVEL',
default: 'info',
validate: Env.oneOf(['trace', 'debug', 'info', 'warn', 'error']),
}),
},
};A boolean predicate is not a validator. validate returns an Effect, so validate: (value) => value > 0 fails at build time and, if the type error is cast away, at runtime — for every variable it is attached to, including the ones that just took their default: EnvValidationError: ... Not a valid effect: true. Write a custom validation function when the built-ins do not fit.
Validation Error
import { EnvValidationError } from '@onebun/core';
try {
await config.initialize();
} catch (error) {
if (error instanceof EnvValidationError) {
console.error(`Invalid value for ${error.variable}: ${error.message}`);
}
}The property is variable — there is no variableName. See the table under Rejected Values Are Never Echoed for what error.value holds.
Deriving values
There is no transform option, and there never was one that ran: a value is resolved, parsed by its declared type and validated, and nothing rewrites it afterwards. TypeScript rejects a transform key; Env.number() also drops it on the floor, while Env.string() keeps it on the config object where nothing ever reads it — so a JS caller, or a TS caller who casts, silently gets the untransformed value.
Declare the shape you actually receive, and derive at the call site:
const envSchema = {
server: {
// Name the unit you are given; convert where you use it
timeoutSeconds: Env.number({ env: 'TIMEOUT_SECONDS', default: 30 }),
},
features: {
// FEATURE_FLAGS='a, b ,c' -> ['a', 'b', 'c'] — items are trimmed
flags: Env.array({ env: 'FEATURE_FLAGS', separator: ',' }),
},
};
const timeoutMs = config.get('server.timeoutSeconds') * 1000;.env File Format
# .env file
PORT=3000
HOST=0.0.0.0
# Database
DATABASE_URL=postgres://user:pass@localhost:5432/mydb
DB_MAX_CONNECTIONS=20
DB_SSL=true
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=secret123
# Features
ALLOWED_ORIGINS=http://localhost:3000,https://example.com
# App
APP_NAME=my-awesome-app
DEBUG=falseMulti-Service Configuration
Override environment variables per service:
const multiApp = new OneBunApplication({
services: {
users: {
module: UsersModule,
port: 3001,
envOverrides: {
// Use different database
'database.url': { fromEnv: 'USERS_DATABASE_URL' },
// Set specific value
'app.name': { value: 'users-service' },
},
},
orders: {
module: OrdersModule,
port: 3002,
envOverrides: {
'database.url': { fromEnv: 'ORDERS_DATABASE_URL' },
'app.name': { value: 'orders-service' },
},
},
},
envSchema,
});Complete Example
// config.ts
import { Env, type InferConfigType } from '@onebun/core';
export const envSchema = {
server: {
port: Env.number({
default: 3000,
env: 'PORT',
validate: Env.port(),
}),
host: Env.string({ default: '0.0.0.0' }),
},
database: {
url: Env.string({
env: 'DATABASE_URL',
required: true,
sensitive: true,
}),
poolSize: Env.number({ default: 10 }),
ssl: Env.boolean({ default: process.env.NODE_ENV === 'production' }),
},
auth: {
jwtSecret: Env.string({
env: 'JWT_SECRET',
required: true,
sensitive: true,
}),
jwtExpiresIn: Env.string({ default: '7d' }),
bcryptRounds: Env.number({ default: 10 }),
},
cache: {
enabled: Env.boolean({ default: true }),
ttl: Env.number({ default: 300 }), // 5 minutes
redis: {
host: Env.string({ default: 'localhost' }),
port: Env.number({ default: 6379 }),
},
},
cors: {
origins: Env.array({
default: ['http://localhost:3000'],
env: 'CORS_ORIGINS',
}),
credentials: Env.boolean({ default: true }),
},
logging: {
level: Env.string({
default: 'info',
validate: Env.oneOf(['trace', 'debug', 'info', 'warn', 'error']),
}),
format: Env.string({
default: 'json',
validate: Env.oneOf(['json', 'pretty']),
}),
},
};
// Automatic type inference
export type AppConfig = InferConfigType<typeof envSchema>;
// Module augmentation for global typed config access
declare module '@onebun/core' {
interface OneBunAppConfig extends AppConfig {}
}
// index.ts
import { OneBunApplication } from '@onebun/core';
import { AppModule } from './app.module';
import { envSchema } from './config';
const app = new OneBunApplication(AppModule, {
envSchema,
envOptions: {
envFilePath: '.env',
},
});
app.start().then(() => {
const logger = app.getLogger();
const config = app.getConfig();
logger.info('Application started', {
port: config.get('server.port'),
config: config.getSafeConfig(), // Sensitive values masked
});
});