Channels
Concepts

Channels

Email, SMS, push, and custom notification channels

A channel defines the shape of one notification type. Email has to, subject, and html. SMS has to and body. Push has title, body, and device tokens. Each channel declares the arguments a send expects, the slots a procedure must configure, and the render function that produces the output a transport delivers.

Built-in channels

Better-Notify ships three channels as separate packages:

Each package exports a channel factory (e.g. emailChannel()) that you pass to createNotify. The factory can accept channel-level defaults — for example, a shared from address for all email procedures.

import { emailChannel } from '@betternotify/email';

const email = emailChannel({
  defaults: {
    from: { name: 'My App', email: 'hello@myapp.com' },
  },
});

const rpc = createNotify({ channels: { email } });

Every procedure built with rpc.email() inherits the defaults. Individual procedures can override them.

Channels and transports

A channel's render function produces a typed output — RenderedMessage for email, RenderedSms for SMS, your own type for custom channels. A transport receives that output and delivers it. The channel never knows which transport is running, and the transport never sees raw input or slot configuration.

This separation is what makes transports swappable. The same email channel works with SMTP, Resend, or a mock — because they all accept RenderedMessage. See Transports for how delivery works.

One route, one channel

Every procedure binds to exactly one channel. When you call rpc.email(), that route is an email route — it cannot also be an SMS route or a Discord route. The type system enforces this: each channel defines its own slots (.subject() and .template() for email, .body() for SMS, .embeds() for Discord), and they are not interchangeable.

To reach users across multiple channels, define a separate procedure per channel and orchestrate sends at the application level:

const rpc = createNotify({ channels: { email, discord, telegram } });

const catalog = rpc.catalog({
  welcomeEmail: rpc.email().input(welcomeInput).subject(/* ... */).template(/* ... */),
  welcomeDiscord: rpc.discord().input(welcomeInput).body(/* ... */).embeds(/* ... */),
  welcomeTelegram: rpc.telegram().input(welcomeInput).body(/* ... */).parseMode('HTML'),
});

After creating the client, send to each channel independently — fire them in parallel when order does not matter:

const [emailResult, discordResult, telegramResult] = await Promise.all([
  mail.welcomeEmail.send({ to: 'ada@example.com', input }),
  mail.welcomeDiscord.send({ input }),
  mail.welcomeTelegram.send({ to: chatId, input }),
]);
DoDon't
RoutingOne procedure per channel — welcomeEmail, welcomeSmsTry to make one procedure send to multiple channels
Shared inputExtract a shared Zod schema and pass it to each procedure's .input()Duplicate the schema definition across procedures
Cross-channel dispatchOrchestrate with Promise.all or sequential await at the call siteExpect a single .send() to fan out across channels
Failover across providersUse multiTransport with 'failover' strategy within one channelMix transports from different channels in multiTransport
Shared defaultsSet channel-level defaults via emailChannel({ defaults })Override channel-specific config from another channel's transport

This constraint is intentional. Each channel has different rendering logic, addressing requirements, and slot shapes — mixing them in a single route would break type safety and make the pipeline unpredictable. Separate routes keep each channel independently testable and deployable.

Custom channels

Any notification type you can model as "validate args → resolve slots → render output" fits as a channel. defineChannel and slot are the only imports you need:

import { defineChannel, slot } from '@betternotify/core';
import { z } from 'zod';

const slackChannel = defineChannel({
  name: 'slack' as const,
  slots: { text: slot.resolver<string>() },
  validateArgs: z.object({
    channel: z.string(),
    threadTs: z.string().optional(),
  }),
  render: ({ runtime, args }) => ({
    channel: args.channel,
    text: runtime.text,
    threadTs: args.threadTs,
  }),
});

Pass the custom channel to createNotify and the builder exposes rpc.slack() with a typed .text() slot — same pattern as the built-in channels.

For a full walkthrough with transport implementation, batch sends, and testing, see Custom Channels.

Anatomy of a channel

Under the hood, every channel — built-in or custom — is built with defineChannel and has four parts:

channel
name"email"
slots
subjectresolver
templatevalue
fromresolver?
tagsvalue?
validateArgsschema or function
renderslots + args → output

Prop

Type

Slots

Slots define what a procedure must configure and how the values are resolved at send time. There are two kinds:

Resolver slots accept a static value or a function that receives { input, ctx } and runs at send time. Use resolvers when the value depends on the procedure's input or context:

import { slot } from '@betternotify/core';

const subject = slot.resolver<string>();

// Procedure can set it as static or dynamic:
rpc.email().subject('Welcome!')
rpc.email().subject(({ input }) => `Welcome, ${input.name}!`)

Value slots accept a static value only. Use values when the configuration is fixed at definition time:

const template = slot.value<TemplateAdapter>();

rpc.email().template({ render: async ({ input }) => ({ html: '...', text: '...' }) })

Both kinds are required by default. Call .optional() to make a slot optional — the procedure can omit it, and render receives undefined:

const tags = slot.value<Tags>().optional();
const priority = slot.value<Priority>().optional();

When the catalog finalizes, Better-Notify checks that every required slot has been set. A missing required slot throws at startup, not at send time.

On this page