Skip to main content

ConfigModule

Built-in@omnitron-dev/titan/module/config

Ships inside @omnitron-dev/titan. No additional install required.

Layered, validated, hot-reloadable configuration. Auto-loaded as a core module by every Titan application (unless disableCoreModules: true). No extra install required — ships inside @omnitron-dev/titan.

When you need it

Every non-trivial backend has configuration that varies by environment, secret, deploy target. ConfigModule provides:

  • Layered sources — file defaults under environment overrides under env-var overrides under command-line overrides.
  • Schema validation at boot — misconfiguration fails immediately rather than at the first dependent service call.
  • Hot reload — change file-based config without restarting (use for log levels, feature flags, anything safe to swap live).
  • Typed injection@Config('database.url') returns the schema's type, not string | undefined.
  • Atomic reload — if reload's validation fails, the old config stays in place; no half-updated state.

Quickstart

import { z } from '@omnitron-dev/titan/validation';
import { ConfigModule } from '@omnitron-dev/titan/module/config';

const AppConfigSchema = z.object({
port: z.number().int().min(1).max(65535),
database: z.object({ url: z.string().url() }),
cache: z.object({ ttlMs: z.number().int().positive().default(60_000) }),
});

@Module({
imports: [
ConfigModule.forRoot({
schema: AppConfigSchema,
sources: [
{ type: 'file', path: 'config/default.yaml' },
{ type: 'file', path: 'config/local.yaml', optional: true },
{ type: 'env', prefix: 'APP_' },
],
validateOnStartup: true,
watchForChanges: true,
}),
],
})
class AppModule {}

IConfigModuleOptions

OptionType
sourcesConfigSource[] (priority — last wins)
schemaAnyZodSchema
environmentstring (default: process.env.NODE_ENV)
validateOnStartupboolean
watchForChangesboolean
cache{ enabled, ttl? } — TTL in ms
strictboolean — throw on missing required values
loggerany
globalboolean

Priority order matters. Sources are processed first-to-last; later sources override earlier ones during deep merge. Put committed defaults first, dev overrides second, environment- sourced secrets last.

ConfigSource union

The five source types each have an explicit shape:

type ConfigSource =
| { type: 'file'; path, format?, encoding?, transform?, optional? }
| { type: 'env'; prefix?, separator?, transform? }
| { type: 'argv'; prefix? }
| { type: 'object'; data } // ← `data`, not `value`
| { type: 'remote'; url, headers?, timeout?, retry? };

Files auto-detect format by extension (json, yaml, toml, ini, env). Env vars become nested paths via separator (default '__').

→ See Configuration / Sources for the full per-source reference.

Services

ClassPurpose
ConfigServiceMain API — get, set, has, delete, onChange, reload, validate, …
ConfigLoaderServiceLoads from sources at startup
ConfigValidatorServiceRuns the schema(s)
ConfigWatcherServiceHot-reload watcher (when watchForChanges: true)

ConfigService API

import { Inject, Service } from '@omnitron-dev/titan';
import { ConfigService, CONFIG_SERVICE_TOKEN } from '@omnitron-dev/titan/module/config';

@Service('users@1.0.0')
class UsersService {
constructor(@Inject(CONFIG_SERVICE_TOKEN) private readonly config: ConfigService) {}

@Public()
async findById(id: string) {
const ttl = this.config.get<number>('cache.ttlMs', 60_000);
return this.cache.getOrSet(`u:${id}`, () => this.repo.findById(id), { ttl });
}
}
MethodPurpose
get<T>(path, defaultValue?)Read a typed value at the dotted path
set(path, value)Write at runtime (not persisted)
has(path)Existence check
getAll()Snapshot of the full config object
getTyped<T>(schema, path?)Validated read — runs the schema against path-subtree
validate(schema?)Validate against a schema (returns IConfigValidationResult)
reload()Force re-load all sources; rolls back on validation fail
onChange(listener)Subscribe to changes; returns unsubscribe function
getMetadata()Source attribution, loaded-at timestamps
environment (getter)The configured environment string

Decorators

import {
Config, InjectConfig, ConfigSchema, Configuration,
ConfigValidate, ConfigWatch, ConfigDefaults,
ConfigProvider, ConfigTransform,
} from '@omnitron-dev/titan/module/config';
DecoratorEffect
@Config(path, default?)Inject a single value
@InjectConfig()Inject the full ConfigService
@ConfigSchema(schema)Class-level Zod schema for this class's config subtree
@Configuration(prefix?)Bind a class as a typed view of a config subtree
@ConfigValidate(schema)Method-level validation of one config subtree
@ConfigWatch(path)Method runs when the watched path changes
@ConfigDefaults({...})Provide class-level defaults
@ConfigProvider(name)Custom config provider class
@ConfigTransform(fn)Property-level value transformer
@Service('users@1.0.0')
class UsersService {
@Config('cache.ttlMs', 60_000)
private readonly cacheTtl!: number;

@InjectConfig()
private readonly config!: ConfigService;

@ConfigWatch('cache.ttlMs')
async onCacheTtlChange(newTtl: number) {
this.cache.setDefaultTtl(newTtl);
}
}

Bound configuration class

@Configuration('cache')
class CacheConfig {
@Config('ttlMs', 60_000) ttlMs!: number;
@Config('maxItems', 10_000) maxItems!: number;
}

// Inject the typed view directly:
constructor(private readonly cache: CacheConfig) {}

@Configuration is the most ergonomic pattern for a service that needs many config values — the typed class doubles as documentation of what config it consumes.

Tokens

TokenPurpose
CONFIG_SERVICE_TOKENInject ConfigService
CONFIG_LOADER_SERVICE_TOKENInject ConfigLoaderService
CONFIG_VALIDATOR_SERVICE_TOKENInject ConfigValidatorService
CONFIG_WATCHER_SERVICE_TOKENInject ConfigWatcherService
CONFIG_OPTIONS_TOKENResolved options bundle
CONFIG_SCHEMA_TOKENGlobal schema

Source merge + validation pipeline

Atomic reload semantics

reload() is atomic: when validation fails after a reload, the service rolls back to the previous config snapshot and throws. You never see a half-updated config inside ConfigService.get mid-call.

try {
await config.reload();
} catch (e) {
// Validation failed; the previous config is still active.
// Log and alert; the running app is not broken.
logger.error({ err: e }, 'config reload validation failed');
}

Multi-environment scaffold

A common production layout for layered config:

config/
├── default.yaml # committed, shared defaults
├── development.yaml # committed, dev overrides
├── staging.yaml # committed, staging overrides
├── production.yaml # committed, prod-safe non-secrets
└── local.yaml # git-ignored, per-developer

Wire it up:

const env = process.env.NODE_ENV ?? 'development';

ConfigModule.forRoot({
schema: AppConfigSchema,
environment: env,
sources: [
{ type: 'file', path: 'config/default.yaml' },
{ type: 'file', path: `config/${env}.yaml`, optional: false },
{ type: 'file', path: 'config/local.yaml', optional: true },
{ type: 'env', prefix: 'APP_' },
{ type: 'argv', prefix: 'app-' },
],
validateOnStartup: true,
watchForChanges: env !== 'production',
});

Priority (low → high): defaults → env-specific → local-dev → process env → CLI argv. Secrets always come from env or argv, never from any committed file.

Remote config source

The 'remote' source fetches from an HTTP endpoint at boot:

sources: [
{ type: 'file', path: 'config/default.yaml' },
{ type: 'remote',
url: 'https://config-server.internal/api/configs/my-api',
headers: { authorization: `Bearer ${process.env.CONFIG_TOKEN}` },
timeout: 5_000,
retry: { attempts: 3, delay: 1_000 },
},
{ type: 'env', prefix: 'APP_' },
],

The loader:

  • Tries attempts times on transport failure.
  • Waits delay ms between attempts (exponential backoff if your loader is so configured).
  • After exhaustion, throws — boot aborts.

For consumers that may be partitioned from the config server, combine a remote source with a file fallback:

sources: [
{ type: 'file', path: 'config/default.yaml' },
{ type: 'remote', url, optional: true }, // skipped on failure
],

When optional: true, a remote-source failure is logged and the boot continues with file defaults only.

Validation error UX

Schema failure on boot raises Errors.badRequest('Configuration validation failed', { errors: [...] }) where each error is:

interface ConfigValidationError {
path: string; // e.g., 'database.url'
message: string; // human-readable
expected?: string;
received?: string;
}

For operator-friendly boot diagnostics, catch and format:

try {
await Application.create({ modules: [AppModule] });
} catch (e) {
if (e?.code === 'BAD_REQUEST' && e.details?.errors) {
console.error('Configuration is invalid:');
for (const err of e.details.errors) {
console.error(` ${err.path}: ${err.message}`
+ (err.expected ? ` (expected ${err.expected}, got ${err.received})` : ''));
}
process.exit(1);
}
throw e;
}

Deep-merge edge cases

The merger is recursive but predictable:

CaseBehaviour
Object × ObjectRecursive merge, last-write-wins per key
Array × ArrayReplace, not concat (avoids accumulating defaults)
Primitive × undefinedKeep the primitive
Primitive × nullnull wins — explicit "remove" semantics
Object × PrimitivePrimitive replaces the object (type change is allowed)
Forbidden keys (__proto__ etc.)Silently skipped — prototype-pollution guard

The forbidden-key guard is non-configurable; the keys are __proto__, constructor, prototype. Any source that tries to set them is ignored.

Cache semantics

ConfigModule.forRoot({
// ...
cache: { enabled: true, ttl: 5_000 },
});

When enabled, get(path) results are cached for ttl ms — useful when many components read the same value on every request. The cache is cleared on every successful reload() and on set() to the affected path.

Skip the cache for paths that change often via set() — the clear-on-write hits the entire cache, not just the changed path, so per-write thrashing can defeat the optimisation.

Strict mode

ConfigModule.forRoot({ strict: true });

When strict: true, get(path) without a defaultValue throws on a missing path instead of returning undefined. Combines well with validateOnStartup: true to make every missing required value loud at boot rather than silent at first-use.

Conventional layout

ConfigModule.forRoot({
schema: AppConfigSchema,
sources: [
{ type: 'file', path: 'config/default.yaml' }, // committed defaults
{ type: 'file', path: 'config/local.yaml', optional: true }, // git-ignored dev overrides
{ type: 'env', prefix: 'APP_' }, // production secrets / per-pod overrides
],
validateOnStartup: true,
watchForChanges: process.env.NODE_ENV !== 'production',
})

Watcher cascade

When watchForChanges: true, the ConfigWatcherService opens an fs-watch on every file source. On change:

Watch is debounced internally — a burst of changes (e.g., editor save with multiple write events) coalesces to one reload.

Lifecycle

HookWhat it does
onInitLoad all sources in order; deep-merge; validate
onStartIf watchForChanges, start file watchers
onStopwatcher.unwatch()
onDestroywatcher.unwatch() again (idempotent)

If validation fails in onInit, the app aborts boot with the underlying error wrapped — no partial start.

Anti-patterns

  • Secrets in committed files. default.yaml should not contain production secrets. Use env vars or a secret manager.
  • No schema. Without a schema, misconfiguration surfaces at the first dependent service call — typically in production, in a rare code path. Schema-validate at boot.
  • Hot-reloading connection strings. A live database URL swap cannot rebind an open connection pool. Reserve hot-reload for log levels, feature flags, and similar safe-to-swap values.
  • Reading process.env in services. Defeats the layered source merge and breaks tests. Read through ConfigService / @Config.
  • Mutating getAll() output. It's a snapshot — mutations don't propagate and surprise the next reader. Use set(path, value) for runtime writes.
  • watchForChanges: true in containers where files don't change. The fs watcher idle-runs forever doing nothing. Disable in production unless you actually deploy config to the pod's filesystem.
  • Treating set() as persistent. It modifies the in-memory config only. The next reload() reverts it. For persistent changes, edit the source file.

See also