TL;DR
- Decorators (Stage 3, TC39): Metadata and behavior modification via
@decoratorsyntax. - TypeScript 5.0+ supports the TC39 standard decorators. Legacy (
experimentalDecorators) for Angular/NestJS. - Patterns: Singleton, Factory, Observer, Strategy, Builder — all type-safe in TypeScript.
- Design patterns solve recurring problems without over-engineering.
Step 1: Decorators (TC39 Standard — TS 5.0+)
Decorators solve the cross-cutting concern problem: logging, caching, retry logic, validation, and access control are needed across many classes/methods but shouldn't be duplicated in each one. The TC39 standard decorators (different from the experimental stage-2 decorators used in Angular/NestJS) landed in TypeScript 5.0, giving JavaScript a native metaprogramming mechanism similar to Python's @decorator or Java's annotations. They're used heavily in backend frameworks (NestJS, TypeORM) and increasingly in frontend code for observable state and dependency injection.
// Class decorator — modifies the class itself
function sealed(constructor: Function) {
Object.seal(constructor);
Object.seal(constructor.prototype);
}
@sealed
class BankAccount {
balance = 0;
}
// Method decorator — wraps a method
function log(target: any, context: ClassMethodDecoratorContext) {
const methodName = String(context.name);
return function (this: any, ...args: any[]) {
console.log(`→ ${methodName}(${args.join(', ')})`);
const result = target.apply(this, args);
console.log(`← ${methodName} returned:`, result);
return result;
};
}
class Calculator {
@log
add(a: number, b: number): number {
return a + b;
}
}
const calc = new Calculator();
calc.add(2, 3);
// → add(2, 3)
// ← add returned: 5
Decorator Factory (With Parameters)
// Factory returns the actual decorator
function minLength(min: number) {
return function (target: any, context: ClassFieldDecoratorContext) {
return function (initialValue: string) {
if (initialValue.length < min) {
throw new Error(`${String(context.name)} must be at least ${min} chars`);
}
return initialValue;
};
};
}
class User {
@minLength(3)
name: string = 'Alice'; // ✅ OK
@minLength(8)
password: string = '123'; // ❌ Throws at runtime
}
Common Decorator Use Cases
// Timing decorator
function timed(target: any, context: ClassMethodDecoratorContext) {
return async function (this: any, ...args: any[]) {
const start = performance.now();
const result = await target.apply(this, args);
const elapsed = (performance.now() - start).toFixed(2);
console.log(`${String(context.name)} took ${elapsed}ms`);
return result;
};
}
// Memoize decorator
function memoize(target: any, context: ClassMethodDecoratorContext) {
const cache = new Map();
return function (this: any, ...args: any[]) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = target.apply(this, args);
cache.set(key, result);
return result;
};
}
// Retry decorator
function retry(attempts: number = 3) {
return function (target: any, context: ClassMethodDecoratorContext) {
return async function (this: any, ...args: any[]) {
for (let i = 0; i < attempts; i++) {
try {
return await target.apply(this, args);
} catch (err) {
if (i === attempts - 1) throw err;
await new Promise(r => setTimeout(r, 1000 * 2 ** i));
}
}
};
};
}
class ApiClient {
@timed
@retry(3)
async fetchData(url: string) {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
}
Step 2: Singleton Pattern
The Singleton pattern ensures a class has exactly one instance throughout your application's lifecycle. It was invented for resources that are expensive to create or must be shared — database connections, configuration managers, logging services, and caches. Without it, you'd either pass the instance through every function call (prop drilling at the class level) or risk creating multiple connections that exhaust resources. In TypeScript, the private constructor + static method approach gives you compile-time enforcement that new Database() can't be called externally.
// Ensures only one instance exists
class Database {
private static instance: Database;
private connection: Connection;
private constructor() {
this.connection = createConnection(process.env.DB_URL!);
}
static getInstance(): Database {
if (!Database.instance) {
Database.instance = new Database();
}
return Database.instance;
}
query(sql: string, params: any[]) {
return this.connection.execute(sql, params);
}
}
// Usage — always same instance
const db1 = Database.getInstance();
const db2 = Database.getInstance();
console.log(db1 === db2); // true
// Module-level singleton (simpler in practice)
// db.ts
const db = new PrismaClient();
export default db;
// Every import gets the same instance
Step 3: Factory Pattern
The Factory pattern decouples object creation from usage — calling code asks for "a notification sender" without knowing whether it gets email, SMS, or push. This was invented because direct instantiation (new EmailSender()) creates tight coupling: if you add a new notification channel, you'd change every call site. Factories centralize that decision, making it trivial to add channels, swap implementations in tests (inject mocks), or choose implementations based on configuration. It's the backbone of plugin architectures and dependency injection containers.
// Creates objects without specifying exact class
interface Notification {
send(to: string, message: string): Promise<void>;
}
class EmailNotification implements Notification {
async send(to: string, message: string) {
await sendEmail(to, message);
}
}
class SMSNotification implements Notification {
async send(to: string, message: string) {
await sendSMS(to, message);
}
}
class PushNotification implements Notification {
async send(to: string, message: string) {
await sendPush(to, message);
}
}
// Factory
type Channel = 'email' | 'sms' | 'push';
function createNotification(channel: Channel): Notification {
switch (channel) {
case 'email': return new EmailNotification();
case 'sms': return new SMSNotification();
case 'push': return new PushNotification();
}
}
// Usage
const notifier = createNotification('email');
await notifier.send('alice@test.com', 'Hello!');
// Abstract factory — for families of related objects
interface UIFactory {
createButton(): Button;
createInput(): Input;
createCard(): Card;
}
class DarkThemeFactory implements UIFactory {
createButton() { return new DarkButton(); }
createInput() { return new DarkInput(); }
createCard() { return new DarkCard(); }
}
Step 4: Observer Pattern (Event System)
The Observer pattern lets objects subscribe to events without the emitter knowing who's listening. It was invented to solve tight coupling in GUI systems (a button shouldn't know about every component that reacts to clicks) and is now used everywhere: DOM events, Node.js EventEmitter, React state subscriptions, WebSocket message handling, and microservice event buses. TypeScript's type system makes it particularly powerful because you can enforce that event names map to specific payload types, catching mismatches at compile time.
// Type-safe event emitter
type EventMap = {
'user:created': { id: string; name: string };
'user:deleted': { id: string };
'order:placed': { orderId: string; total: number };
};
class TypedEventEmitter<T extends Record<string, any>> {
private listeners = new Map<keyof T, Set<Function>>();
on<K extends keyof T>(event: K, listener: (data: T[K]) => void): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(listener);
// Return unsubscribe function
return () => this.listeners.get(event)?.delete(listener);
}
emit<K extends keyof T>(event: K, data: T[K]): void {
this.listeners.get(event)?.forEach(listener => listener(data));
}
}
// Usage
const events = new TypedEventEmitter<EventMap>();
// Type-safe! TypeScript knows the shape of the data
const unsubscribe = events.on('user:created', (data) => {
console.log(`New user: ${data.name}`); // data is { id: string; name: string }
});
events.emit('user:created', { id: '1', name: 'Alice' }); // ✅
events.emit('user:created', { id: '1' }); // ❌ Type error: missing 'name'
unsubscribe(); // Clean up
Step 5: Strategy Pattern
The Strategy pattern lets you swap algorithms at runtime without changing the code that uses them. It was invented because business logic often has multiple valid approaches to the same problem: different pricing tiers, different sorting algorithms depending on data size, different authentication methods per environment. Without Strategy, you end up with massive if/else chains that grow with every new variant. With it, each algorithm is an independent class you can test in isolation, add without modifying existing code, and inject based on context.
// Swap algorithms at runtime
interface PricingStrategy {
calculate(basePrice: number, quantity: number): number;
}
class RegularPricing implements PricingStrategy {
calculate(basePrice: number, quantity: number): number {
return basePrice * quantity;
}
}
class BulkPricing implements PricingStrategy {
calculate(basePrice: number, quantity: number): number {
const discount = quantity >= 100 ? 0.20 : quantity >= 50 ? 0.10 : 0;
return basePrice * quantity * (1 - discount);
}
}
class SubscriberPricing implements PricingStrategy {
constructor(private discount: number) {}
calculate(basePrice: number, quantity: number): number {
return basePrice * quantity * (1 - this.discount);
}
}
// Context class uses any strategy
class ShoppingCart {
constructor(private pricing: PricingStrategy) {}
setPricing(strategy: PricingStrategy) {
this.pricing = strategy;
}
getTotal(items: { price: number; quantity: number }[]): number {
return items.reduce(
(total, item) => total + this.pricing.calculate(item.price, item.quantity),
0
);
}
}
// Usage — swap strategy based on user type
const cart = new ShoppingCart(new RegularPricing());
if (user.isSubscriber) {
cart.setPricing(new SubscriberPricing(0.15));
} else if (order.quantity >= 50) {
cart.setPricing(new BulkPricing());
}
Step 6: Builder Pattern
The Builder pattern constructs complex objects step by step, letting you create different configurations with the same construction process. It was invented for objects with many optional parameters — instead of constructors with 10+ arguments (where you forget which null goes where), builders use named methods that make the intent clear. In TypeScript, method chaining with return this creates a fluent API, and you can even use conditional types to enforce that required steps are called before .build().
// Construct complex objects step by step
class QueryBuilder {
private table: string = '';
private conditions: string[] = [];
private ordering: string = '';
private limitValue: number | null = null;
private columns: string[] = ['*'];
select(...cols: string[]): this {
this.columns = cols;
return this; // Enable chaining
}
from(table: string): this {
this.table = table;
return this;
}
where(condition: string): this {
this.conditions.push(condition);
return this;
}
orderBy(column: string, direction: 'ASC' | 'DESC' = 'ASC'): this {
this.ordering = `ORDER BY ${column} ${direction}`;
return this;
}
limit(n: number): this {
this.limitValue = n;
return this;
}
build(): string {
let query = `SELECT ${this.columns.join(', ')} FROM ${this.table}`;
if (this.conditions.length) {
query += ` WHERE ${this.conditions.join(' AND ')}`;
}
if (this.ordering) query += ` ${this.ordering}`;
if (this.limitValue) query += ` LIMIT ${this.limitValue}`;
return query;
}
}
// Usage — reads like English
const query = new QueryBuilder()
.select('name', 'email', 'created_at')
.from('users')
.where('active = true')
.where('age >= 18')
.orderBy('created_at', 'DESC')
.limit(10)
.build();
// SELECT name, email, created_at FROM users WHERE active = true AND age >= 18 ORDER BY created_at DESC LIMIT 10
Step 7: Repository Pattern
The Repository pattern abstracts data access behind a clean interface, so your business logic doesn't know (or care) whether data comes from PostgreSQL, MongoDB, an API, or in-memory storage. It was invented to separate domain logic from infrastructure concerns — the same service code works whether you're running unit tests (in-memory repo), integration tests (test database), or production (real database). It's the most-used pattern in backend TypeScript (NestJS, TypeORM, Prisma all use variations) and is essential for testable architecture.
// Abstract data access behind a clean interface
interface Repository<T> {
findById(id: string): Promise<T | null>;
findAll(filter?: Partial<T>): Promise<T[]>;
create(data: Omit<T, 'id'>): Promise<T>;
update(id: string, data: Partial<T>): Promise<T>;
delete(id: string): Promise<void>;
}
// Concrete implementation (easily swappable)
class PrismaUserRepository implements Repository<User> {
constructor(private prisma: PrismaClient) {}
async findById(id: string) {
return this.prisma.user.findUnique({ where: { id } });
}
async findAll(filter?: Partial<User>) {
return this.prisma.user.findMany({ where: filter });
}
async create(data: Omit<User, 'id'>) {
return this.prisma.user.create({ data });
}
async update(id: string, data: Partial<User>) {
return this.prisma.user.update({ where: { id }, data });
}
async delete(id: string) {
await this.prisma.user.delete({ where: { id } });
}
}
// In tests — swap implementation
class InMemoryUserRepository implements Repository<User> {
private users: User[] = [];
async findById(id: string) {
return this.users.find(u => u.id === id) ?? null;
}
// ... easy to test without a database
}
Interview Questions
-
What are TC39 decorators vs legacy decorators?
- TC39 (Stage 3) decorators are the standard, supported in TS 5.0+. They have a different API (
contextparameter). Legacy decorators (experimentalDecorators: true) are used by Angular/NestJS and havereflect-metadata. Both coexist but new projects should prefer TC39.
- TC39 (Stage 3) decorators are the standard, supported in TS 5.0+. They have a different API (
-
When would you use the Strategy pattern?
- When you have multiple algorithms for the same task and want to swap them at runtime. Examples: pricing rules, sorting algorithms, validation strategies, authentication methods. Avoids large if/else chains.
-
What's the difference between Factory and Builder?
- Factory: creates objects in one step based on a parameter (type → object). Builder: constructs complex objects step-by-step with a fluent API (many optional configurations). Use Builder when objects have many optional parameters.
-
Why use the Repository pattern?
- Abstracts data access behind an interface. Benefits: easy to swap databases, easy to test (mock/in-memory), separates business logic from data logic, single place for query optimization.