Skip to content

Configuration

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.

Each plugin gets one file: config/<namespace>.ts. Use Config.define(Provider, overrides) to produce a branded value the framework recognises:

config/cache.ts
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:

  1. 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.
  2. 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.

Warden itself ships two configs: discord and warden. You override them with the DiscordConfig and WardenConfig carrier classes — used exactly like any plugin provider:

config/discord.ts
import {Config, DiscordConfig, env} from '@warden/core';
export default Config.define(DiscordConfig, {
token: env.required('DISCORD_TOKEN'),
applicationId: env.required('DISCORD_APPLICATION_ID'),
});
config/warden.ts
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.

env() is how you read environment variables inside config files:

import {env} from '@warden/core';
env('OPTIONAL_KEY'); // string | undefined
env('OPTIONAL_KEY', 'fallback'); // string — returns fallback when not set
env.required('REQUIRED_KEY'); // string — throws at boot if not set

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

When the bot starts, for each plugin namespace Warden:

  1. Registers the plugin’s defaults as the starting point.
  2. Looks for a matching config/<namespace>.ts file.
  3. Deep-merges the consumer’s overrides onto the defaults.
  4. Stores the result where this.config() and Config.forProvider(...) can read it.

The merge rules match what you’d expect:

Value typeMerge behaviour
Plain objectsMerged recursively (consumer keys win on conflict)
ArraysReplaced wholesale — your array fully overrides the default
Class instancesReplaced wholesale — new RedisAdapter() fully supersedes new MemoryAdapter()
FunctionsReplaced wholesale
PrimitivesReplaced wholesale
undefined in overridesIgnored — 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.

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.

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/cache

This 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:

TagWhat it’s for
configConfig stubs that land in config/<namespace>.ts
translationsLocale files — @warden/i18n picks them up from lang/<locale>/<plugin>.json automatically
anything elseCustom 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.

Here’s the full starter flow, condensed:

  • Directorymy-bot/
    • Directorysrc/
      • app.ts
      • Directorycommands/
      • Directoryconfig/
        • discord.ts
        • warden.ts
        • cache.ts
        • audit.ts
    • package.json
src/config/cache.ts
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')}),
});
src/app.ts
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.