@nexcord/core
v0.0.3
Published
<div align="center">
Readme
Nexcord
A decorator-driven, dependency-injection-powered framework for building Discord bots with discord.js and TypeScript — built for Bun.
Nexcord takes the ergonomics of decorator/DI frameworks like NestJS or Angular and applies them to Discord bot development. Instead of wiring up event listeners, command handlers, and services by hand, you describe what each class is with a decorator, and Nexcord handles discovery, instantiation, and dependency injection for you.
@Service()
export class GreetingService {
greet(username: string) {
return `Welcome aboard, ${username}! 🎉`;
}
}
@Listener()
export class MemberEvents {
constructor(private readonly greeting: GreetingService) {}
@On('guildMemberAdd')
async onJoin(member: GuildMember) {
await member.send(this.greeting.greet(member.user.username));
}
}No manual new GreetingService(), no manual client.on('guildMemberAdd', ...) wiring, no manual file imports. Nexcord discovers both classes, resolves the dependency, and boots them for you.
Table of Contents
- Features
- The Nexcord Ecosystem
- Installation
- Quick Start
- Recommended Project Structure
- Core Concepts
- Beta Status & Known Limitations
- Contributing
- License
✨ Features
- Decorator-based architecture —
@Service(),@Listener(),@SlashCommand(), and@Guard()describe your classes; Nexcord does the rest. - Per-feature isolated containers — every decorator type (
@Service,@Guard,@SlashCommand,@Listener) is registered and managed through its own dedicated container class, keeping registration logic isolated while dependency resolution stays centralized. - Recursive constructor injection — powered by
reflect-metadata. Services can depend on other services, which can depend on more services, resolved automatically and instantiated as singletons. - Robust circular dependency resolution — a Lazy Proxy mechanism backed by a centralized container layer (
DependencyContainer+ServiceContainer) that tracks resolution state and instantiation order explicitly. - Compile-time type checking for event handlers —
@On()and@Once()validate your handler's parameter types against the actual discord.js event signature viaDcEvents<T>, so a mistyped parameter fails to compile instead of failing at runtime. - Convention-based autoloading — drop a file in the right folder and it's registered. No manual import lists to maintain.
- Route Guard Forwarding — a type-safe, pattern-matching middleware system that can gate any command or event, including wildcards, prefix/suffix matches, and regular expressions.
- Built-in services for common bot needs: channel lookups, guild lookups, role caching, and shared configuration.
- A real ecosystem, not just a core —
@nexcord/tsxlets you write components as JSX, and@nexcord/configgives you a DI-aware shared config store. - Built for Bun — no build step required to run your bot in development.
🧩 The Nexcord Ecosystem
Nexcord is split into small, focused packages. @nexcord/core is the only one you strictly need — the others are optional but designed to plug in seamlessly.
| Package | Description |
| --- | --- |
| @nexcord/core | The framework itself — bootstrapping, dependency injection, decorators, guards, and built-in services. You are here. |
| @nexcord/tsx | Write buttons, modals, embeds, and Discord Components V2 layouts as JSX instead of chaining builder methods. |
| @nexcord/config | A tiny, dependency-injection-aware shared config store that ConfigService reads from — with helpers to register config that depends on your services or on the bot being ready. |
You can add either companion package at any point — neither is required to get a bot running.
📦 Installation
Nexcord is developed for and with Bun, and its autoloading layer is built on standard CommonJS-style require() calls, so a Node.js + ts-node/tsx setup works as well.
bun add @nexcord/core discord.js reflect-metadatanpm install @nexcord/core discord.js reflect-metadata
pnpm add @nexcord/core discord.js reflect-metadata
yarn add @nexcord/core discord.js reflect-metadataNexcord's decorators rely on TypeScript's experimental decorator metadata, so make sure your tsconfig.json enables it:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": true
}
}🚀 Quick Start
Create your bot's entry point and bootstrap it with NexCord:
// src/index.ts
import 'reflect-metadata';
import { GatewayIntentBits } from 'discord.js';
import { NexCord } from '@nexcord/core';
NexCord
.setIntents(
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers
)
.run(process.env.DISCORD_TOKEN!);DISCORD_TOKEN=your-bot-token bun run src/index.tsThat's it — run() recursively loads your commands, events, configs, and common folders, registers everything it finds, and logs in.
Need to run logic once the bot is actually online — with services already resolved for you? Pass a factory:
import { NexCord } from '@nexcord/core';
import { GuildService } from '@nexcord/core';
NexCord.run(process.env.DISCORD_TOKEN!, {
inject: [GuildService],
factory: async (guilds: GuildService) => {
console.log(`Logged in as ${NexCord.client.user?.tag}`);
}
});📁 Recommended Project Structure
Nexcord doesn't force a rigid structure on you, but its autoloader looks for a small set of conventional folders under src/. A typical project looks like this:
src/
├── commands/ # @SlashCommand() classes — autoloaded
│ ├── Ping.command.ts
│ └── Info.command.ts
├── events/ # @Listener() classes — autoloaded
│ ├── Ready.listener.ts
│ └── Member.listener.ts
├── configs/ # registerConfig() definitions (@nexcord/config) — autoloaded
│ └── general.config.ts
├── common/ # Shared building blocks — autoloaded
│ ├── services/ # @Service() classes
│ │ └── Greeting.service.ts
│ ├── guards/ # @Guard() classes
│ │ └── Cooldown.guard.ts
│ └── actions/ # @nexcord/tsx component action controllers
│ └── Confirm.action.ts
├── components/ # .tsx component definitions (@nexcord/tsx)
│ └── confirm.components.tsx
└── index.ts # Bootstrap entry pointFour top-level folders are autoloaded automatically (see DirEnum below): commands, events, configs, and common. Anything else — services, guards, components, or any structure you prefer — works fine as long as it's imported, directly or indirectly, from a file that is autoloaded. Decorators run the moment a class's module is evaluated, so a @Service() class gets registered as soon as anything imports it, regardless of which folder it physically lives in.
🧠 Core Concepts
Bootstrapping — the NexCord class
NexCord is a static entry point that controls your bot's lifecycle, imported from @nexcord/core:
| Method | Description |
| --- | --- |
| NexCord.setIntents(...intents) | Overrides the default GatewayIntentBits (Nexcord enables all intents by default). |
| NexCord.setClient(client) | Provide your own pre-configured discord.js Client instance instead of letting Nexcord create one. |
| NexCord.setGuild(guildId) | Resolves and caches a target Guild globally, handy for single-guild bots. |
| NexCord.get(targetClass) | Retrieves a registered @Service() / @Listener() / @SlashCommand() / @Guard() instance from the DI cache. |
| NexCord.run(token, options?) | Recursively loads your project folders, registers everything, and logs in. Accepts an optional { factory, inject } pair — see below. |
| NexCord.client | The underlying discord.js Client, available once the bot has bootstrapped. |
setIntents, setClient, and setGuild are chainable, so you configure the bot fluently before calling run().
Autoloading & Directory Conventions
When NexCord.run() is called, its loader walks four directories (relative to your project root) and require()s every TypeScript file it finds, which is what triggers your decorators to run and register themselves:
| Directory | Purpose |
| --- | --- |
| src/commands | Slash command classes |
| src/events | Event listener classes |
| src/configs | Configuration registration files |
| src/common | Everything shared — services, guards, actions, and anything else |
Dependency Injection
Any class decorated with @Service(), @Listener(), @SlashCommand(), or @Guard() participates in Nexcord's DI container. Constructor parameters are inspected via reflect-metadata, and their dependencies are resolved recursively: if A depends on B, and B depends on C, Nexcord instantiates C first, injects it into B, then injects B into A — all as singletons, so every consumer shares the same instance.
import { Service } from '@nexcord/core';
@Service()
export class QuoteService {
private readonly quotes = [
'Ship it.',
'Works on my machine.',
'It is always a race condition.'
];
random(): string {
return this.quotes[Math.floor(Math.random() * this.quotes.length)]!;
}
}Any other @Service(), @Listener(), @SlashCommand(), or @Guard() class can now simply ask for a QuoteService in its constructor and receive a fully resolved instance.
New in 0.0.3 — per-feature container isolation: under the hood, every decorator type now owns its own dedicated container class rather than being managed through a single loosely-shared structure. A @Guard() class's registration, its route definitions, and its instantiation are entirely the responsibility of its own container — with no crossover into how @Service() or @SlashCommand() classes are tracked. Each container exposes its own add() / get() contract, but all of them ultimately hand off instance creation and dependency resolution to the same centralized ServiceContainer. In other words: registration and definition are now split per feature and fully isolated from one another, while instantiation and dependency resolution remain centralized. This means a change to one container's internal logic (say, an update to Guard route matching) can no longer leak into the behavior of unrelated containers, which was possible in earlier versions.
🔄 Circular Dependency Resolution
In complex architectures, you might encounter scenarios where two services depend on each other (e.g., AService needs BService, and BService needs AService). Typically, this leads to runtime crashes or undefined properties because one of the services hasn't been instantiated yet.
Nexcord solves this seamlessly using a Lazy Proxy mechanism combined with the @Inject() decorator and the InjectType<T> utility type. When a circular dependency is detected, Nexcord injects a dynamic JS Proxy instead of an uninitialized instance. The real instance is resolved dynamically from the container the moment a method is called.
New in 0.0.3: the Proxy mechanism itself hasn't gone anywhere — it's still what makes circular access safe at the method-call level. What's changed is what sits underneath it. Circular detection and instance bookkeeping are now handled by a dedicated container layer (DependencyContainer for dependency graphs, ServiceContainer for singleton instances and resolution state), instead of being handled ad hoc during resolution. This makes the cycle-breaking behavior explicit and traceable: when a cycle is detected, the already-registered service is re-flagged (circular: false) and its instance is rebuilt through the container rather than relying solely on the Proxy to paper over an incomplete instantiation.
Here is how you can handle circular dependencies:
// A.service.ts
import { Inject, Service, type InjectType } from '@nexcord/core'
import { BService } from './B.service'
@Service()
export class AService {
// 1. Wrap the target service in a function closure inside @Inject
// 2. Use InjectType<T> to ensure proper TypeScript typings and autocomplete
constructor(@Inject(() => BService) private readonly bService: InjectType<BService>) { }
a() {
this.bService.a()
}
b() {
console.log('Method b executed in AService')
}
}
// B.service.ts
import { Inject, Service, type InjectType } from '@nexcord/core'
import { AService } from './A.service'
@Service()
export class BService {
constructor(@Inject(() => AService) private readonly aService: InjectType<AService>) { }
b() {
this.aService.b()
}
a() {
console.log('Method a executed in BService')
}
}
Now, any other service can consume these seamlessly without worrying about initialization order:
// Logger.service.ts
import { Service } from '@nexcord/core';
import { AService } from './A.service';
import { BService } from './B.service';
import { TestService } from './Test.service';
@Service()
export class LoggerService {
constructor(
private readonly testService: TestService,
private readonly aService: AService,
private readonly bService: BService,
) { }
log(...args: any[]) {
this.aService.a() // Safely resolves BService behind the scenes!
this.bService.b() // Safely resolves AService behind the scenes!
console.log(this.testService.test(), ...args)
}
}
How it works under the hood: The framework tracks instantiations through its container layer. If it detects a cycle during the resolution phase, it leaves a
Proxyplaceholder in the constructor arguments array while the container records the cycle and re-registers the affected service. Once the bootstrap process finishes and the bot is fully ready, any interaction with the proxy transparently routes calls to the fully built singleton inside theServiceContainer.
Services
Services are where your business logic belongs — keep commands and listeners thin, and let services do the heavy lifting. Just decorate a class with @Service(); there's no interface to implement.
Slash Commands
A slash command is a class decorated with @SlashCommand(name, description) that extends BaseSlashCommand and implements two hooks:
build(builder)— add options, subcommands, or permissions. Return the builder (sync or async).handler(interaction)— runs when the command is invoked.
// src/commands/Quote.command.ts
import { BaseSlashCommand, SlashCommand } from '@nexcord/core';
import {
CommandInteraction,
SlashCommandBuilder,
type SlashCommandOptionsOnlyBuilder
} from 'discord.js';
import { QuoteService } from '../common/services/Quote.service';
@SlashCommand('quote', 'Sends a random developer quote.')
export class QuoteCommand implements BaseSlashCommand {
constructor(private readonly quotes: QuoteService) {}
build(b: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder) {
return b;
}
async handler(interaction: CommandInteraction): Promise<void> {
await interaction.reply(this.quotes.random());
}
}Notice QuoteService is simply requested in the constructor — Nexcord resolves and injects it automatically.
Event Listeners
Event listeners are classes decorated with @Listener(). Inside, individual methods are decorated with @On(event) (fires every time) or @Once(event) (fires once).
// src/events/Member.listener.ts
import { Listener, On } from '@nexcord/core';
import { GuildMember } from 'discord.js';
import { GreetingService } from '../common/services/Greeting.service';
@Listener()
export class MemberEvents {
constructor(private readonly greeting: GreetingService) {}
@On('guildMemberAdd')
async onJoin(member: GuildMember) {
await member.send(this.greeting.greet(member.user.username)).catch(() => {});
}
}Type-Safe Event Parameters with DcEvents<T>
Re-enabled and updated in 0.0.3. @On() and @Once() now validate, at compile time, that your handler's parameters match the actual signature discord.js defines for the event name you passed in. This existed at the core level in an earlier form but had been disabled; 0.0.3 updates the implementation and turns it back on.
The DcEvents<'eventName'> utility type resolves to the exact parameter tuple discord.js emits for that event, so you can spread it directly into your handler's signature:
@On('clientReady')
async ready(...[client]: DcEvents<'clientReady'>) {
this.loggerService.log(`${client.user?.username} active`)
}If you mistype the parameter here — for example, annotating client as string instead of letting DcEvents<'clientReady'> infer it — TypeScript will fail to compile, because DcEvents<'clientReady'> is bound to discord.js's actual [Client<true>] signature for that event and the decorator enforces it.
Note: this check is only as precise as the event name you provide. Passing a literal string (as in the examples above) gives you full type narrowing. If you pass a dynamic or generic value (e.g.
@On(someVariable)),DcEvents<T>won't be able to narrow to a specific signature.
Guards & Route Forwarding
Guards are Nexcord's middleware system, and its most distinctive feature. A @Guard() class declares one or more forwarding routes that connect a guard method to a listener method. If the guard method returns (or resolves to) false, the target's execution is aborted before it ever runs.
@Guard(listeners, ...routes: Forward<T, Ls>[])listeners— an object mapping a name to a listener (or other registered) class, e.g.{ MemberEvents }.routes— one or more strings of the form"guardMethod->ListenerName::listenerMethod". TypeScript validates this format at compile time via theForward<T, Ls>type — if the target method doesn't exist, your build fails.
Route strings support pattern matching, so a single guard can protect many targets at once:
| Pattern | Matches | Example |
| --- | --- | --- |
| * | Anything, in any position | checkAuth->*::* — every method of every listener |
| prefix% | Names starting with prefix | on% matches onMessage, onReady |
| %suffix | Names ending with suffix | %Listener matches MemberEvents (if aliased accordingly) |
| %contains% | Names containing contains anywhere | %check% matches checkSpam |
| /regex/ | Names matching a regular expression | /^isAdmin/ matches isAdminUser |
// src/common/guards/Cooldown.guard.ts
import { Guard } from '@nexcord/core';
import { Message } from 'discord.js';
import { HypeListener } from '../../events/Hype.listener';
@Guard(
{ HypeListener },
'throttle->HypeListener::onMessage'
)
export class CooldownGuard {
private readonly lastTrigger = new Map<string, number>();
async throttle(message: Message): Promise<boolean> {
const last = this.lastTrigger.get(message.author.id) ?? 0;
if (Date.now() - last < 10_000) return false; // still cooling down — abort
this.lastTrigger.set(message.author.id, Date.now());
return true;
}
}Guard forwarding fires both when a listener's event is dispatched and when a slash command is invoked, so the same mechanism can be used to gate permission checks on commands as well as events.
Built-in Services
Nexcord ships a handful of @Service() classes out of the box, ready to be injected wherever you need them:
| Service | Purpose |
| --- | --- |
| ChannelService | Fast lookup and validation helpers for text, voice, DM, and "sendable" channels. |
| ConfigService | A dot-notation reader/writer (get<T>(path) / set(path, value)) over the shared store from @nexcord/config. |
| GuildService | Simple guild lookup helper, complementing NexCord.setGuild(). |
| RoleService | Loads a guild's roles and caches them into config as roles.<name> / roles.<id>. Exposes helpers like hasUser(userId, ...roles). |
⚠️ Beta Status & Known Limitations
Nexcord is beta software. The public API described above is stable enough to build on, but expect rough edges:
DcEvents<T>type narrowing only works with literal event-name strings passed to@On()/@Once()— dynamic or generic event names won't be narrowed.- APIs may still shift between minor releases while the framework stabilizes toward a
1.0.
Found a bug or a rough edge? Please open an issue — feedback at this stage directly shapes the 1.0 API.
🤝 Contributing
Issues and pull requests are welcome. If you're proposing a larger change (a new decorator, a change to the DI resolution order, etc.), please open an issue first to discuss the approach.
📄 License
Released under the MIT License.
Built by SignorMassimo · Part of the Nexcord ecosystem alongside @nexcord/tsx and @nexcord/config
