Recommended Reading: This article leverages several TypeScript type-system features. If you'd like to dive deeper into how they work under the hood, check out these official documentation resources:
Why build a notification factory in the first place?
The answer is simple: you want to avoid repeating code and follow one of the core software engineering principles: DRY (Don't Repeat Yourself).
Consider a common backend scenario where you need to notify a user that their account has been banned. If the UserBanningService (responsible for banning the player) constructs notification messages itself, it must also understand notification formatting and localization.
Now imagine your team decides to update the Arabic or English translation. Suddenly, you're modifying the UserBanningService, even though its responsibility has nothing to do with localization. This violates the Single Responsibility Principle.
It gets even worse when new languages are introduced. If the application needs to support Spanish tomorrow, you could end up updating dozens of services just to add another translation. Besides being time-consuming, this approach is error-prone and difficult to maintain.
Instead, we want a solution where notification content is defined once and domain services only need to specify what happened, not how the notification is constructed.
That leads us to the following requirements.
The Requirements
- All notification strings and placeholders (
message_en,message_ar, etc.) must live in one place. - Payload-Type Synchronization: Selecting a notification type should automatically determine the exact payload shape required by the compiler. For example, if a notification requires a username and a duration, the payload should be:
typescript
{ bannedUsername: string; durationInDays: number; } - Optional Arguments for Payload-less Notifications: Notifications that don't require dynamic data should accept no payload at all. We'll represent this using
void. - Compile-Time Enforcement: Missing properties, incorrect property types, or unnecessary payloads should all produce TypeScript compiler errors before the code ever runs.
Step 1: Type Contracts
First, we define an enum for all notification triggers across the application:
// notification-type.enum.ts
export enum NotificationType {
REPORTED_PLAYER_BANNED = 'REPORTED_PLAYER_BANNED',
USERNAME_CHANGE_APPROVED = 'USERNAME_CHANGE_APPROVED',
USERNAME_CHANGE_REJECTED = 'USERNAME_CHANGE_REJECTED',
}Next, we map each NotificationType directly to the payload it expects. Notifications that require dynamic data are mapped to an object describing that data, while notifications without any dynamic content are mapped to void.
// notification-payloads.interface.ts
import { NotificationType } from './notification-type.enum';
export interface NotificationPayloads {
[NotificationType.REPORTED_PLAYER_BANNED]: {
bannedUsername: string;
durationInDays: number;
};
[NotificationType.USERNAME_CHANGE_APPROVED]: void;
[NotificationType.USERNAME_CHANGE_REJECTED]: void;
}Why
void?As we will see when building the factory signature, TypeScript treats
voidspecially inside conditional tuple types, allowing us to omit the payload parameter entirely when calling the method!
Step 2: Centralizing Localized Templates
Now let's create our single source of truth for notification templates. Each template stores localized message strings using {placeholder} syntax for values that will be injected at runtime.
// notification-templates.ts
import { NotificationType } from './notification-type.enum';
export interface LocalizedTemplate {
message_en: string;
message_ar: string;
}
export const NotificationTemplates: Record<NotificationType, LocalizedTemplate> = {
[NotificationType.REPORTED_PLAYER_BANNED]: {
message_en: 'The reported player {bannedUsername} has been banned for {durationInDays} days.',
message_ar: 'تم حظر اللاعب المُبلغ عنه {bannedUsername} لمدة {durationInDays} أياّم.',
},
[NotificationType.USERNAME_CHANGE_APPROVED]: {
message_en: 'Your request to change your username has been approved.',
message_ar: 'تمت الموافقة على طلب تغيير اسم المستخدم الخاص بك.',
},
[NotificationType.USERNAME_CHANGE_REJECTED]: {
message_en: 'Your request to change your username has been rejected.',
message_ar: 'تم رفض طلب تغيير اسم المستخدم الخاص بك.',
},
};If your team decides to add Spanish support tomorrow, you only need to add message_es to the LocalizedTemplate interface and update this dictionary. Not a single line of domain logic in other services needs to change.
Step 3: The Factory Implementation
Here is the core NotificationFactory implementation written in pure TypeScript:
// notification-factory.ts
import { NotificationType } from './notification-type.enum';
import { NotificationPayloads } from './notification-payloads.interface';
import { NotificationTemplates, LocalizedTemplate } from './notification-templates';
export class NotificationFactory {
/**
* Generates localized notification messages based on the notification type
* and its corresponding type-safe payload.
*/
createNotification<K extends NotificationType>(
type: K,
...args: NotificationPayloads[K] extends void
? []
: [payload: NotificationPayloads[K]]
): LocalizedTemplate {
const template = NotificationTemplates[type];
const payload = args[0];
return {
message_en: this.interpolate(template.message_en, payload),
message_ar: this.interpolate(template.message_ar, payload),
};
}
/**
* Replaces placeholders such as {bannedUsername}
* with values from the provided payload.
*/
private interpolate(
template: string,
params?: Record<string, unknown>,
): string {
if (!params) {
return template;
}
return template.replace(/\{(\w+)\}/g, (match, key) => {
if (params[key] !== undefined && params[key] !== null) {
return String(params[key]);
}
return match;
});
}
}Breaking Down the Factory Signature
At first glance, the createNotification method signature might look intimidating. Fortunately, it's built from a few TypeScript features working together, and each one has a straightforward responsibility.
createNotification<K extends NotificationType>(
type: K,
...args: NotificationPayloads[K] extends void
? []
: [payload: NotificationPayloads[K]]
)Let's examine it piece by piece.
1. Generic Type Parameter (K extends NotificationType)
The generic type parameter K represents the specific notification type passed into the method.
For example:
factory.createNotification(
NotificationType.REPORTED_PLAYER_BANNED,
...
);In this call, TypeScript infers K as the literal type NotificationType.REPORTED_PLAYER_BANNED, not just the broader NotificationType enum.
That distinction is important because it allows every subsequent type in the method signature to depend on the selected notification.
2. Indexed Access Types (NotificationPayloads[K])
Once K is known, TypeScript performs a lookup on the NotificationPayloads interface.
If K is:
NotificationType.REPORTED_PLAYER_BANNEDthen
NotificationPayloads[K]evaluates to:
{
bannedUsername: string;
durationInDays: number;
}If instead K is:
NotificationType.USERNAME_CHANGE_APPROVEDthen the lookup evaluates to:
voidThis gives us a compile-time mapping between every notification type and the payload it expects.
3. Conditional Variadic Tuple Types
This is where everything comes together.
...args: NotificationPayloads[K] extends void
? []
: [payload: NotificationPayloads[K]]The rest parameter is defined using a conditional type.
If the payload type resolves to void, the tuple becomes:
[]meaning no additional arguments are accepted.
Otherwise, it becomes:
[payload: NotificationPayloads[K]]meaning exactly one payload argument is required.
As a result, the compiler automatically enforces the correct method signature for every notification type without requiring overloads or separate factory methods.
Step 4: Real-World Usage Example
Let's look at how domain modules in a real-world application consume our NotificationFactory:
// app.ts
import { NotificationFactory } from './notification-factory';
import { NotificationType } from './notification-type.enum';
const factory = new NotificationFactory();
const banNotification = factory.createNotification(
NotificationType.REPORTED_PLAYER_BANNED,
{
bannedUsername: 'ShadowNinja99',
durationInDays: 14,
}
);
console.log(banNotification.message_en);
// Output: "The reported player ShadowNinja99 has been banned for 14 days."
console.log(banNotification.message_ar);
// Output: "تم حظر اللاعب المُبلغ عنه ShadowNinja99 لمدة 14 أياّم."
const approvalNotification = factory.createNotification(
NotificationType.USERNAME_CHANGE_APPROVED
);
console.log(approvalNotification.message_en);
// Output: "Your request to change your username has been approved."
// Error: Expected 2 arguments, but got 1. (Payload missing!)
factory.createNotification(NotificationType.REPORTED_PLAYER_BANNED);
// Error: Type 'number' is not assignable to type 'string'
factory.createNotification(NotificationType.REPORTED_PLAYER_BANNED, {
bannedUsername: 12345,
durationInDays: 7,
});
// Error: Expected 1 argument, but got 2. (Void type given an unnecessary payload!)
factory.createNotification(NotificationType.USERNAME_CHANGE_APPROVED, {
extra: 'data',
});Conclusion
Although we built this example around notifications, the same pattern applies anywhere an event determines the shape of its associated data.
For example, imagine two microservices communicating through a message queue. Each event requires its own payload structure, and producers should only be allowed to publish events with the correct data. By mapping each event to its payload and letting TypeScript infer the relationship, you can enforce those contracts entirely at compile time.
The result is a solution that:
- Centralizes localized templates in one place.
- Eliminates repetitive message construction across domain services.
- Automatically synchronizes notification types with their payloads.
- Surfaces mistakes at compile time instead of runtime.
- Scales naturally as new notification types or languages are introduced.
The notification factory is just one application of this pattern. Any system where one identifier determines the shape of another object can benefit from the same approach.
Thank you for reading! I hope this article helped you learn something new. If you have any questions, suggestions, or feedback, feel free to reach out to me on LinkedIn or by email.