Writing a custom channel
Implement a ChannelDriver and the library discovers it automatically. Build an SMS channel end to end — the driver, a type-safe notification interface, and a global module.
A channel is just a class that implements ChannelDriver: a channel name and a send() method.
Register it as a provider and the ChannelRegistry
discovers it via Nest's DiscoveryService — no registration array, no central list. Let's build an
SMS channel against a fictional SMS API.
Export a channel handle and a notification interface
First, create the channel handle with createChannel('sms'). The handle is callable as a method
decorator (@Sms()) and usable as a type-safe token in via() (return [Sms]) — exactly like the
built-in Mail and Database handles. Export it next to the message type and a notification
interface for compile-time safety.
import { createChannel } from '@dudousxd/nestjs-notifications-core';
/** Channel handle: use as `@Sms()` on a payload method, or as a token in `via()`. */
export const Sms = createChannel('sms');
export interface SmsMessage {
body: string;
}
/** Implement this on any notification routed to the `sms` channel for type safety. */
export interface SmsNotification {
toSms(ctx: ChannelContext): SmsMessage;
}createChannel(name) takes just the channel name — the same string your driver reports as its
channel. That one string is the only thing the handle and the driver need to agree on.
Implement the driver
The driver reads the recipient's phone number from routeNotificationFor('sms'), pulls the payload
from the notification's toSms(), and calls the API. If the notification was routed to sms but
never implemented toSms(), throw MissingChannelMethodError — the same error the built-in
channels raise, so the failure is consistent and clear.
Use getHandler(notification, 'sms', 'toSms') to resolve the payload method: it returns the
@Sms()-decorated method if present, otherwise the toSms convention name — so both styles work,
just like the built-in channels.
import {
type ChannelDriver,
MissingChannelMethodError,
type Notifiable,
type Notification,
getHandler,
} from '@dudousxd/nestjs-notifications-core';
import { Injectable } from '@nestjs/common';
import type { SmsMessage } from './sms-notification';
import { SmsClient } from './sms-client';
@Injectable()
export class SmsChannel implements ChannelDriver {
readonly channel = 'sms';
constructor(private readonly client: SmsClient) {}
async send(notifiable: Notifiable, notification: Notification): Promise<void> {
const to = notifiable.routeNotificationFor(this.channel, notification) as string | undefined;
if (!to) return; // no SMS address for this recipient — nothing to do
const handler = getHandler(notification, this.channel, 'toSms');
if (!handler) {
throw new MissingChannelMethodError(
this.channel,
'toSms',
(notification.constructor as { notificationName?: string }).notificationName ??
notification.constructor.name,
);
}
const message = handler({ notifiable }) as SmsMessage;
await this.client.send({ to, body: message.body });
}
}getHandler returns the bound method (or undefined), and MissingChannelMethodError takes the
channel name, the missing method name, and the notification name — it builds the message for you.
Provide it as a global module
Expose the driver as a provider in a global module. Global registration is what lets the registry discover the channel app-wide, the same pattern the built-in channels use.
import { type DynamicModule, Module } from '@nestjs/common';
import { SmsChannel } from './sms.channel';
import { SmsClient } from './sms-client';
@Module({})
export class SmsChannelModule {
static forRoot(): DynamicModule {
return {
module: SmsChannelModule,
global: true, // discoverable everywhere
providers: [SmsClient, SmsChannel],
exports: [SmsChannel],
};
}
}Then import it alongside NotificationsModule:
@Module({
imports: [
EventEmitterModule.forRoot(),
NotificationsModule.forRoot(),
SmsChannelModule.forRoot(),
],
})
export class AppModule {}Discovery is structural, not nominal. ChannelRegistry scans every provider in the container and
keeps any whose instance has a string channel and a send() method — keyed by channel. If two
drivers claim the same name, the last one discovered wins, so keep channel names unique.
Use it in a notification
Because you exported the Sms handle, notifications opt into the channel exactly like the built-ins:
annotate the payload method with @Sms() and the channel is inferred — no via() to maintain.
import { Notification } from '@dudousxd/nestjs-notifications-core';
import { Sms, type SmsMessage } from './sms-notification';
@Notification()
export class OtpCode {
constructor(private readonly code: string) {}
@Sms()
toSms(): SmsMessage {
return { body: `Your verification code is ${this.code}` };
}
}The Sms handle also works as a via() token (via() { return [Sms]; }), and implementing
SmsNotification adds compile-time checks on toSms().
await notifications.send(user, new OtpCode('492013'));That's the whole contract. The recipient supplies the address through
routeNotificationFor('sms'), the notification supplies the payload through toSms(), and the
runner handles events and the error policy for you.
See also
- Channels and dispatchers — how discovery works
- Notifications —
via()and per-channel payloads - Reference: configuration — error classes and the error policy
Injecting services
Pull a provider into a notification with NestJS's own @Inject. The notification stays new-able with plain data, and the library fills the service from the Nest container at delivery time — sync and queued.
Telescope integration
Record every notification delivery in the nestjs-telescope dashboard with one watcher. It listens to the events the core already emits — no monkey-patching, no call-site changes.