Configuration
Configs, the Warden way
Section titled “Configs, the Warden way”Every Warden plugin declares a typed config shape in its ServiceProvider, with sensible defaults baked in. You configure a plugin by writing a single file under config/ that overrides just the fields you care about — everything else inherits. Values are always typed; the same static field on the provider drives both the author-time defaults and the consumer-time autocomplete.
You’ll probably never type env(...) inside a command or service. env reads belong in config files, where the configured value is then available everywhere via the provider’s this.config() reader.
Writing a config file
Section titled “Writing a config file”Each plugin gets one file: config/<namespace>.ts. Use Config.define(Provider, overrides) to produce a branded value the framework recognises:
import {Config, env} from '@warden/core';import {CacheServiceProvider, RedisAdapter} from '@warden/cache';
export default Config.define(CacheServiceProvider, { adapter: new RedisAdapter({url: env.required('REDIS_URL')}),});Two things worth noticing:
- The second argument is fully typed against the plugin’s
CacheConfig. You get autocomplete on every field, and any field you don’t override inherits the plugin’s default. - Runtime instances (like
new RedisAdapter()) live right next to the serialisable stuff. Configs are TypeScript modules, not data files — they can hold anything you want, including callbacks and class instances.
Framework configs
Section titled “Framework configs”Warden itself ships two configs: discord and warden. You override them with the DiscordConfig and WardenConfig carrier classes — used exactly like any plugin provider:
import {Config, DiscordConfig, env} from '@warden/core';
export default Config.define(DiscordConfig, { token: env.required('DISCORD_TOKEN'), applicationId: env.required('DISCORD_APPLICATION_ID'),});import {Config, WardenConfig, env} from '@warden/core';
export default Config.define(WardenConfig, { ownerId: env('OWNER_ID'),});config/discord.ts is effectively required — without a token and applicationId, startup fails loudly. config/warden.ts is optional; its defaults (shutdownTimeout: 10_000, logLevel: 'info') kick in automatically when you don’t write one.
The env() helper
Section titled “The env() helper”env() is how you read environment variables inside config files:
import {env} from '@warden/core';
env('OPTIONAL_KEY'); // string | undefinedenv('OPTIONAL_KEY', 'fallback'); // string — returns fallback when not setenv.required('REQUIRED_KEY'); // string — throws at boot if not setUse env.required(...) liberally for anything your bot can’t run without. The error is loud, early, and unambiguous — much better than a silent undefined propagating to a runtime crash two calls deep.
Keep env calls inside config/*.ts files. Everywhere else in your code, read from this.config() or Config.forProvider(...) — the environment becomes an implementation detail of how configs are populated.
How merging works
Section titled “How merging works”When the bot starts, for each plugin namespace Warden:
- Registers the plugin’s
defaultsas the starting point. - Looks for a matching
config/<namespace>.tsfile. - Deep-merges the consumer’s overrides onto the defaults.
- Stores the result where
this.config()andConfig.forProvider(...)can read it.
The merge rules match what you’d expect:
| Value type | Merge behaviour |
|---|---|
| Plain objects | Merged recursively (consumer keys win on conflict) |
| Arrays | Replaced wholesale — your array fully overrides the default |
| Class instances | Replaced wholesale — new RedisAdapter() fully supersedes new MemoryAdapter() |
| Functions | Replaced wholesale |
| Primitives | Replaced wholesale |
undefined in overrides | Ignored — the default is kept |
The rule of thumb: only plain-object fields with nested object fields get a recursive merge. Anything else — arrays, instances, primitives — is a full replacement.
Reading configs at runtime
Section titled “Reading configs at runtime”Providers read their own config via this.config():
export default class CacheServiceProvider extends ServiceProvider { static configNamespace = 'cache'; static defaults: CacheConfig = {adapter: new MemoryAdapter()};
register() { const cfg = this.config<CacheConfig>(); // typed CacheConfig const service = new CacheService(cfg.adapter); this.container.registerInstance(CacheService, service); }}Code outside a provider (middleware, services, commands) reads via the injected Config service:
import {Config, WardenConfig} from '@warden/core';import {injectable} from 'tsyringe';
@injectable()class SomeMiddleware { constructor(private config: Config) {}
handle() { const {ownerId} = this.config.forProvider(WardenConfig); // typed // ... }}Config.for<T>(namespace) is the untyped form if you need it — give it your own type parameter. Config.forProvider(ProviderClass) is the typed alternative that infers from the provider’s defaults.
Publishing stubs
Section titled “Publishing stubs”A plugin ships a starter config file (a “stub”) that consumers can copy into their project and edit. Warden’s warden publish command does the copying:
warden publish cache --package=@warden/cacheThis resolves @warden/cache, finds every exported class with a static publishes array, and copies those stubs into your project at the declared to paths.
Flags:
--package=<name>— tells the command which npm package to import. Required.--tag=<tag>— filter to one tag. Default is everything.--force— overwrite existing destinations. Without it, the command fails atomically if any target file already exists.--all— publish every provider found in the package (useful when a package ships several providers).
A plugin’s publishes entry looks like this:
static publishes = [ { from: new URL('../stubs/cache.stub.ts', import.meta.url), to: 'config/cache.ts', tag: 'config', }, { from: new URL('../stubs/en.json', import.meta.url), to: 'lang/en/cache.json', tag: 'translations', },] as const;Three conventional tags:
| Tag | What it’s for |
|---|---|
config | Config stubs that land in config/<namespace>.ts |
translations | Locale files — @warden/i18n picks them up from lang/<locale>/<plugin>.json automatically |
| anything else | Custom asset tags: migrations, views, etc. |
Stubs are ordinary .ts files, copied verbatim. No templating, no variable substitution. If the stub has an env.required('X') in it, the consumer gets that call in their copy — a loud, early signal that they need to set the env var.
A worked example end-to-end
Section titled “A worked example end-to-end”Here’s the full starter flow, condensed:
Directorymy-bot/
Directorysrc/
- app.ts
Directorycommands/
- …
Directoryconfig/
- discord.ts
- warden.ts
- cache.ts
- audit.ts
- package.json
import {Config, env} from '@warden/core';import {CacheServiceProvider, RedisAdapter} from '@warden/cache';
export default Config.define(CacheServiceProvider, { adapter: new RedisAdapter({url: env.required('REDIS_URL')}),});import 'reflect-metadata';import 'dotenv/config';import {Bot} from '@warden/core';import {CacheServiceProvider} from '@warden/cache';import {AuditServiceProvider} from '@warden/audit';
new Bot() .discover(d => d .config('./src/config/**/*.ts') .interactions('./src/{commands,buttons,modals,menus}/**/*.ts') .events('./src/events/**/*.ts')) .intents(i => i.defaults()) .plugins([ CacheServiceProvider, AuditServiceProvider, ]) .start();Consumers who want to start from a plugin’s template run warden publish cache --package=@warden/cache once, edit the result, and never think about config boilerplate again.