Typed notifications
Declare notification types once with define() and get type-checked notify() calls.
Typed notification definitions let you declare each notification type once.
notify() then derives the payload type, title, body, and the
preferences UI from that single declaration. The lightweight ad-hoc form stays
available for one-off sends.
define and the notifications config key
Wrap each type with define<Payload> and pass a notifications map to
betterPush:
import { betterPush, define } from "@better-push/core";
import { drizzleAdapter } from "@better-push/core/adapters/drizzle";
import { webPush } from "@better-push/core/providers/web-push";
import { db } from "@/db";
export const push = betterPush({
database: drizzleAdapter(db),
providers: [webPush({ vapid: { /* ... */ } })],
notifications: {
orderShipped: define<{ orderId: string; eta: string }>({
title: (p) => `Order ${p.orderId} shipped`,
body: (p) => `Arriving ${p.eta}`,
data: (p) => ({ url: `/orders/${p.orderId}` }),
label: "Order shipped",
group: "Orders",
}),
newComment: define<{ postId: string; author: string }>({
title: (p) => `New comment from ${p.author}`,
label: "New comment",
group: "Social",
}),
},
session: async (request) => {
/* ... */
},
});betterPush captures the notifications map in its type, so the returned
push.notify is checked against your definitions.
Definition fields
| Field | Meaning |
|---|---|
title | Static string, or (payload) => string. Required. |
body | Static string, or (payload) => string | undefined. Optional. |
channels | Channels this type targets. Default ["push", "inApp"]. |
data | (payload) => Record<string, unknown>, deep-merged under args.data. |
label | Human label for the preferences UI. Defaults to the type key. |
group | Optional grouping bucket in the preferences UI. |
defaultEnabled | Fallback when no preference row exists. Default true. |
Invalid definitions throw a BetterPushError at construction naming the type:
every definition needs a title; channels, if present, is a non-empty subset
of ["push", "inApp"]; and a type key may not be "*" (reserved for wildcard
preferences).
The two notify forms
Typed (primary)
await push.notify("orderShipped", {
userId: user.id,
payload: { orderId: "1024", eta: "tomorrow" },
// channels?: override the definition's channels
// data?: merged over definition.data(payload)
});The payload is inferred from define<Payload>. A wrong payload shape is a
compile error; an unknown type key is a compile error too. When a definition's
Payload is void, payload is optional.
Ad-hoc (one-off sends)
await push.notify({
userId: user.id,
title: "Something happened",
body: "No declared type needed.",
data: { url: "/" },
// type?: defaults to "default"; channels?: defaults to both
});An app that never declares a type can use only this form - notifications is
optional. If an ad-hoc type happens to match a registered definition, that
definition's defaultEnabled and label still apply for preference resolution.
Resolution
For the typed form, better-push computes:
channels = args.channels ?? definition.channels ?? ["push", "inApp"]title = typeof def.title === "function" ? def.title(payload) : def.titlebodylikewisedata = { ...def.data?.(payload), ...args.data }
The result flows into the normal send pipeline and is gated by preferences.
Root exports
define, NotificationDefinition, and NotificationDefinitions are exported
from the package root (@better-push/core).