TypeScript types
All types are exported from @notifykitjs/core. This page documents the most commonly referenced ones.
End-to-end inference
Define a payload once — TypeScript narrows send() inputs, hook contexts, and result types automatically.
Single import path
Every type ships from @notifykitjs/core. No hunting across subpackages or peer dependencies.
Utility extractors
InferPayload, InferNotificationId, and InferSendInput derive types from your instance — no manual duplication.
Exhaustive skip reasons
A typed union of every possible skip reason so switch statements catch all cases at compile time.
| Type | Used for |
|---|---|
NotificationDefinition | Defining notifications |
ChannelConfig | Inbox, email, SMS, webhook channel templates |
Recipient | User profiles with contact info and quiet hours |
InboxItem | Inbox entries with read/archive state |
DeliveryRecord | Delivery attempts and their outcomes |
RecipientPreference | Per-notification channel opt-in/out |
EmailProvider / SmsProvider | Implementing custom providers |
Queue / RetryPolicy | Custom queue implementations |
SkipReason | Understanding why a channel was skipped |
Importing types
Everything exports from a single path. Import only what you need:
import type {
SendResult,
InboxItem,
DeliveryRecord,
RecipientPreference,
EmailProvider,
SkipReason,
} from "@notifykitjs/core"| Building | Types you'll reach for |
|---|---|
| Notification definitions | NotificationDefinition, ChannelConfig, DigestConfig |
| Send result handling | SendResult, SkipReason, DeliveryRecord |
| Custom provider | EmailProvider, SmsProvider, WebhookProvider |
| Custom queue | Queue, RetryPolicy, DeliveryJob |
| UI / client code | InboxItem, RecipientPreference |
How types flow through the system
Types aren't isolated — they flow from your definition through the engine and into results. Understanding these connections helps you write typed utilities without casting:
You write: id, payload schema, channels. TypeScript infers Id and PayloadSchema as literal types.
Narrowed by notificationId. Pass "comment_mentioned" and TypeScript enforces that notification's payload shape.
Contains NotificationRecord, InboxItem[], DeliveryRecord[], SkippedDelivery[]. All typed — no unknown fields.
Hooks receive typed objects: delivery.sent gives you a DeliveryRecord, notification.created gives you a NotificationRecord.
| You define | TypeScript infers | You get back |
|---|---|---|
id: "comment_mentioned" | Literal type "comment_mentioned" | send() only accepts this exact string for notificationId |
payload: { actorName: "string" } | { actorName: string } | send() requires actorName when this ID is used |
channels: [inbox(...), email(...)] | Channel config union | result.deliveries[].channel is "inbox" | "email" |
as const on the array | Tuple of all definitions | notificationId is a union of all registered IDs, not string |
as const assertion is critical. Without it, TypeScript widens "comment_mentioned" to string and you lose all narrowing. Your send()call would accept any string as notificationId and any object as payload — defeating the purpose.Extracting types from definitions
You don't need to manually duplicate your payload types. Extract them from the definitions themselves:
import type { InferPayload, InferNotificationId } from "@notifykitjs/core"
import type { notify } from "@/lib/notifykit"
type NotificationId = InferNotificationId<typeof notify>
// → "comment_mentioned" | "order_shipped" | "team_invite"
type CommentPayload = InferPayload<typeof notify, "comment_mentioned">
// → { actorName: string; postTitle: string; postUrl: string }
function trackNotificationSent(id: NotificationId, payload: Record<string, unknown>) {
analytics.track("notification_sent", { notificationId: id, ...payload })
}| Utility type | Extracts | Use for |
|---|---|---|
InferNotificationId<T> | Union of all registered notification IDs | Typed switch statements, exhaustiveness checks, analytics events |
InferPayload<T, Id> | Payload shape for a specific notification | Typing helper functions that construct payloads, test factories |
InferSendInput<T> | Full send() input union | Wrapping send() in a queue job or server action |
import type { InferSendInput } from "@notifykitjs/core"
import type { notify } from "@/lib/notifykit"
type SendJob = InferSendInput<typeof notify>
async function enqueueSend(job: SendJob) {
await queue.add("notification:send", job)
}
async function processSend(job: SendJob) {
await notify.send(job)
}type CommentPayload = { actorName: string; ... } by hand, use InferPayload instead. The definition is the single source of truth — derived types stay in sync automatically when you add or rename fields.Type map
Every type belongs to one layer. Use this to quickly find what you're looking for:
| Layer | Types | You touch these when |
|---|---|---|
| Definition | NotificationDefinition, ChannelConfig, DigestConfig, RateLimitConfig | Writing notification definitions in lib/notifications/ |
| Input | InferSendInput, InferPayload, InferNotificationId | Typing wrappers around send() — queues, server actions, helpers |
| Output | SendResult, InboxItem, DeliveryRecord, SkipReason | Inspecting what happened after a send — logging, analytics, conditional UI |
| State | Recipient, RecipientPreference, QuietHours, SecurityScope | Managing user profiles, preferences, and tenant scoping |
| Extension | EmailProvider, SmsProvider, WebhookProvider, Queue, RetryPolicy, DatabaseAdapter | Building custom providers, queues, or database adapters |
SendResult, InboxItem, and SkipReason for result handling. The Definition layer is handled by the notification() helper, and Extension types are only needed when building custom integrations.NotificationDefinition
id, payload, and channels. Everything else is opt-in as your needs grow.type NotificationDefinition<Id extends string, S extends PayloadSchema> = {
// Required
id: Id
payload: S
channels: ChannelConfig[]
// Delivery control
digest?: DigestConfig<S>
rateLimit?: RateLimitConfig
fallback?: InboxChannelConfig | FallbackRule[]
required?: boolean
defaultChannels?: ChannelPreferenceMap
// Metadata
description?: string
category?: string
classification?: "transactional" | "product" | "marketing"
version?: number
// Security & validation
redact?: readonly string[]
validate?: (payload: unknown) => Record<string, unknown>
}Channel configs
type InboxChannelConfig = {
type: "inbox"
title: string
body?: string
actionUrl?: string
}
type EmailChannelConfig = {
type: "email"
subject: string
body: string
html?: boolean
}
type SmsChannelConfig = {
type: "sms"
body: string
}
type WebhookChannelConfig = {
type: "webhook"
url: string
headers?: Record<string, string>
}
type ChannelConfig = InboxChannelConfig | EmailChannelConfig | SmsChannelConfig | WebhookChannelConfig
type ChannelType = "inbox" | "email" | "sms" | "webhook"Recipient
type Recipient = {
id: string
tenantId?: string
workspaceId?: string
email?: string
phone?: string
name?: string
quietHours?: QuietHours | null
createdAt: Date
updatedAt: Date
}
type QuietHours = {
start: string // "HH:MM" 24h format
end: string // "HH:MM" 24h format
timezone?: string // IANA timezone, defaults to "UTC"
}InboxItem
type InboxItem = {
id: string
notificationRecordId: string
recipientId: string
tenantId?: string
workspaceId?: string
notificationId: string
title: string
body?: string
actionUrl?: string
readAt?: Date | null
archivedAt?: Date | null
createdAt: Date
}DeliveryRecord
type DeliveryRecord = {
id: string
notificationRecordId: string
recipientId: string
tenantId?: string
workspaceId?: string
notificationId: string
channel: "email" | "webhook" | "sms" | "inbox"
provider: string
status: "pending" | "sent" | "failed" | "skipped"
to?: string
subject?: string
body?: string
providerMessageId?: string
error?: string
skipReason?: SkipReason
skipDetails?: string
attempts: number
createdAt: Date
updatedAt: Date
sentAt?: Date | null
failedAt?: Date | null
}RecipientPreference
type RecipientPreference = {
recipientId: string
tenantId?: string
workspaceId?: string
notificationId: string
channels: ChannelPreferenceMap
updatedAt: Date
}
type ChannelPreferenceMap = Partial<Record<ChannelType, boolean>>Provider interfaces
| Interface | Input | Return |
|---|---|---|
EmailProvider | to, subject, body | { providerMessageId? } — stored on the delivery record for tracking |
SmsProvider | to, body | |
WebhookProvider | url, headers, payload |
type EmailProvider = {
id: string
send(input: { to: string; subject: string; body: string }): Promise<{ providerMessageId?: string }>
}
type SmsProvider = {
id: string
send(input: { to: string; body: string }): Promise<{ providerMessageId?: string }>
}
type WebhookProvider = {
id: string
signed?: boolean
send(input: {
url: string
headers: Record<string, string>
payload: {
notificationId: string
recipientId: string
tenantId?: string
workspaceId?: string
payload: Record<string, unknown>
sentAt: string
}
}): Promise<{ providerMessageId?: string }>
}Queue & retry
type Queue = {
enqueue(job: DeliveryJob, run: (job: DeliveryJob) => Promise<void>): void | Promise<void>
drain(): Promise<void>
}
type RetryPolicy = {
maxAttempts: number
delayMs(attempt: number): number
}SecurityScope
type SecurityScope = {
tenantId?: string
organizationId?: string // alias for tenantId
workspaceId?: string
}SkipReason
When a channel is skipped, result.skipped[].reason tells you why. Reasons are grouped by the pipeline stage that produced them — earlier stages short-circuit the entire send, later stages affect individual channels.
Early pipeline (stops entire send)
| Reason | Stage | What happened | Fix |
|---|---|---|---|
idempotent_replay | 1. Idempotency | Key already exists — original result returned | Expected on retries. Not a problem. |
duplicate | 2. Dedup | Dedup key matched within the window | Expected. If surprising, check your key design. |
invalid_payload | 3. Validation | Payload failed schema validation | Fix the payload — check error.fields. |
rate_limited | 4. Rate limit | Exceeded threshold for this notification | Increase the limit, or add a digest to batch instead of drop. |
Per-channel (skips one channel, others may still deliver)
| Reason | Stage | What happened | Fix |
|---|---|---|---|
preferences_disabled | 5. Preferences | User opted out of this channel | Expected. User chose this — don't override unless required. |
unsubscribed | 5. Preferences | User clicked unsubscribe link in email | Same as above — respect the opt-out. |
condition_false | 5. Preferences | A conditional channel rule evaluated to false | Check your channel condition logic. |
quiet_hours_deferred | 6. Quiet hours | Deferred — will deliver when window ends | Not truly skipped — check back after quiet hours pass. |
missing_address | 7. Delivery | Recipient has no email/phone for this channel | Call upsertRecipient() with the missing field. |
missing_provider | 7. Delivery | No provider configured for this channel type | Add a provider in createNotifyKit(). |
Meta (informational)
| Reason | What happened | Notes |
|---|---|---|
suppressed | All channels were skipped — notification had no visible effect | Check individual skip reasons to find why each channel failed. |
expired | Scheduled send expired before delivery window opened | Usually means quiet hours ended but the configured TTL passed first. |
disabled_in_dev | Channel disabled by devMode configuration | Only in development — won't appear in production. |
required_override | Preferences were bypassed by required: true | Informational — channel did deliver despite user opt-out. |
Working with SendResult
SendResult is the most common type you'll interact with. Here are practical patterns for inspecting and acting on it:
import type { SendResult, SkipReason } from "@notifykitjs/core"
async function sendAndLog(result: SendResult) {
const delivered = result.deliveries.length > 0 || result.inboxItems.length > 0
if (result.rateLimited) return "dropped_by_rate_limit"
if (result.digested) return "buffered_in_digest"
if (result.idempotent) return "duplicate_replay"
const skippedEmail = result.skipped.find(s => s.channel === "email")
if (skippedEmail) {
switch (skippedEmail.reason) {
case "preferences_disabled":
break
case "missing_address":
await promptUserForEmail(result.notification?.recipientId)
break
case "missing_provider":
logger.warn("No email provider configured")
break
}
}
const channels = result.deliveries.map(d => d.channel)
analytics.track("notification_sent", {
id: result.notification?.notificationId,
channels,
skipped: result.skipped.map(s => `${s.channel}:${s.reason}`),
})
return delivered ? "delivered" : "suppressed"
}| Pattern | Fields to check | Use case |
|---|---|---|
| Did anything deliver? | deliveries.length + inboxItems.length > 0 | Showing "Notification sent" vs "Notification suppressed" toasts |
| Why was email skipped? | skipped.find(s => s.channel === "email")?.reason | Debugging "user didn't get email" support tickets |
| Is this a retry replay? | result.idempotent === true | Avoiding double-counting in analytics |
| Was it deferred? | result.deferredChannels.length > 0 | Showing "Will deliver at 8am" in the UI |
result.notification is null only while a digest is buffered for a later flush.DatabaseAdapter
Implement this interface to connect NotifyKit to any storage backend. The built-in memoryAdapter() and drizzlePostgresAdapter() both satisfy this contract. The outline below is abbreviated; import the exported type for exact inputs and return values:
type DatabaseAdapter = {
recipients: {
upsert(input: UpsertRecipientInput): Promise<Recipient>
findById(id: string): Promise<Recipient | null>
}
notifications: {
create(input: NewNotificationRecord): Promise<NotificationRecord>
findByIdempotencyKey(key: string): Promise<NotificationRecord | null>
clearIdempotencyKey(id: string): Promise<void>
}
inbox: {
create(input: NewInboxItem): Promise<InboxItem>
listByRecipient(recipientId: string, scope?, filter?, limit?): Promise<InboxItem[]>
markReadForRecipient(id: string, recipientId: string, scope?): Promise<MarkReadResult>
unreadCount(recipientId: string, scope?): Promise<number>
markAllRead(recipientId: string, scope?): Promise<number>
archiveForRecipient(id: string, recipientId: string, scope?): Promise<InboxItemResult>
unarchiveForRecipient(id: string, recipientId: string, scope?): Promise<InboxItemResult>
deleteForRecipient(id: string, recipientId: string, scope?): Promise<DeleteResult>
// plus listByNotificationRecordId(...)
}
deliveries: { /* create, findById, list, update, listByNotificationRecordId */ }
preferences: { /* get, list, upsert */ }
digests: { /* append, take, restore, list */ }
rateLimits: { /* reserve, count */ }
scheduledSends: { /* create, claim, complete, release, listDue, list */ }
dedupe: { /* check, exists, prune */ }
timeline?: { /* append, listByNotificationRecordId, listByDeliveryId, prune */ }
}| Method group | Called by | Must be atomic |
|---|---|---|
| Notifications | send() pipeline | No — single writes |
| Inbox | Handler routes + send() | markAllRead should be atomic (updates many rows) |
| Deliveries | Queue workers + retry logic | No — updates are per-record |
| Dedup/idempotency | send() early checks | Yes — dedupe.check() must be an atomic check-and-insert |
| Scheduled sends | Quiet hours + flush | Yes — scheduledSends.claim() must prevent double-processing |
memoryAdapter() source. It's ~200 lines and implements every method with plain arrays and Maps. Copy it as a starting point for Redis, DynamoDB, or any custom backend — the contract is the same regardless of storage.Common type errors
TypeScript errors in NotifyKit are usually caused by one of five issues. Match the error message to the fix:
| Error | Cause | Fix |
|---|---|---|
Argument of type 'string' is not assignable to parameter of type '"comment_mentioned" | "order_shipped"' | Variable typed as string instead of the literal union | Use as const on the value, or type the variable as InferNotificationId<typeof notify> |
Property 'actorName' is missing in type '{}' | Payload doesn't match the schema for this notification ID | Add the missing field. Use InferPayload<typeof notify, "your_id"> to see the expected shape. |
Type 'string' is not assignable to type 'never' on notificationId | Missing as const on the notifications array in createNotifyKit() | Change notifications: [...] to notifications: [...] as const |
Object literal may only specify known properties on payload | Passing extra fields not declared in the notification's payload schema | Remove the extra field, or add it to the notification definition's payload object |
Type 'undefined' is not assignable to type 'string' on provider send | Custom provider's send() return doesn't match EmailProvider | Return { providerMessageId?: string } — even {} satisfies the contract |
// ❌ Error: string ≠ literal union
const id = getNotificationFromConfig()
await notify.send({ recipientId: "u1", notificationId: id, payload: {} })
// ✅ Fix 1: type assertion (when validated externally)
await notify.send({
recipientId: "u1",
notificationId: id as InferNotificationId<typeof notify>,
payload: getPayloadForId(id),
})
// ✅ Fix 2: typed lookup (preferred)
const NOTIFICATION_MAP = {
comment: "comment_mentioned",
order: "order_shipped",
} as const
await notify.send({
recipientId: "u1",
notificationId: NOTIFICATION_MAP.comment,
payload: { actorName: "Rey", postUrl: "/p/1" },
})as any to silence NotifyKit type errors. They exist to catch real bugs — a wrong notification ID means the payload won't match the template, and the notification will render broken or throw at runtime. Fix the type, don't suppress it.