TypeScript

Advanced Class Patterns

32 min Lesson 14 of 40

Advanced Object-Oriented Patterns in TypeScript

Beyond basic class features, TypeScript offers advanced patterns that enable sophisticated object-oriented designs. This lesson explores mixins, decorators, the this type, and other advanced class techniques.

Mixins Pattern

Mixins allow you to compose behavior from multiple sources into a single class. TypeScript supports mixins through a combination of class expressions and interfaces:

// Type for constructor functions type Constructor<T = {}> = new (...args: any[]) => T; // Mixin function: adds timestamp functionality function Timestamped<TBase extends Constructor>(Base: TBase) { return class extends Base { timestamp = new Date(); getTimestamp(): Date { return this.timestamp; } }; } // Mixin function: adds activation functionality function Activatable<TBase extends Constructor>(Base: TBase) { return class extends Base { isActive = false; activate(): void { this.isActive = true; console.log("Activated!"); } deactivate(): void { this.isActive = false; console.log("Deactivated!"); } }; } // Base class class User { constructor(public name: string) {} } // Apply mixins const TimestampedUser = Timestamped(User); const ActiveUser = Activatable(TimestampedUser); const user = new ActiveUser("Alice"); console.log(user.name); // "Alice" console.log(user.getTimestamp()); // Date object user.activate(); // "Activated!" console.log(user.isActive); // true // Combine multiple mixins function applyMixins<T extends Constructor, M extends Constructor[]>( Base: T, ...mixins: M ): T { return mixins.reduce((combined, mixin) => mixin(combined), Base) as T; } class Product { constructor(public name: string, public price: number) {} } const EnhancedProduct = applyMixins(Product, Timestamped, Activatable); const product = new EnhancedProduct("Laptop", 999); console.log(product.name); // "Laptop" console.log(product.price); // 999 console.log(product.getTimestamp()); // Date object product.activate(); // "Activated!"
Note: Mixins are a powerful alternative to multiple inheritance. They allow you to compose functionality from multiple sources without the complexity of traditional inheritance hierarchies.

The this Type

TypeScript's special this type refers to the type of the containing class, enabling fluent interfaces and method chaining:

// Fluent interface using this type class Calculator { private value: number = 0; add(n: number): this { this.value += n; return this; } subtract(n: number): this { this.value -= n; return this; } multiply(n: number): this { this.value *= n; return this; } divide(n: number): this { if (n !== 0) { this.value /= n; } return this; } getResult(): number { return this.value; } } // Method chaining const result = new Calculator() .add(10) .multiply(2) .subtract(5) .getResult(); console.log(result); // 15 // This type works with inheritance class ScientificCalculator extends Calculator { power(n: number): this { this.add(Math.pow(this.getResult(), n) - this.getResult()); return this; } sqrt(): this { this.add(Math.sqrt(this.getResult()) - this.getResult()); return this; } } const sciResult = new ScientificCalculator() .add(16) .sqrt() // Returns ScientificCalculator, not Calculator .power(2) // Still chainable .getResult(); console.log(sciResult); // 16 // Builder pattern with this type class QueryBuilder<T> { private filters: string[] = []; private sortField?: keyof T; private limit?: number; where(field: keyof T, value: any): this { this.filters.push(`${String(field)} = ${value}`); return this; } orderBy(field: keyof T): this { this.sortField = field; return this; } take(count: number): this { this.limit = count; return this; } build(): string { let query = "SELECT * FROM table"; if (this.filters.length > 0) { query += ` WHERE ${this.filters.join(" AND ")}`; } if (this.sortField) { query += ` ORDER BY ${String(this.sortField)}`; } if (this.limit) { query += ` LIMIT ${this.limit}`; } return query; } } interface User { id: number; name: string; age: number; } const query = new QueryBuilder<User>() .where("age", 25) .where("name", "Alice") .orderBy("id") .take(10) .build(); console.log(query); // SELECT * FROM table WHERE age = 25 AND name = Alice ORDER BY id LIMIT 10

Generic Classes with Constraints

Combine generics with class constraints for powerful abstractions:

// Generic repository pattern interface Entity { id: number; } class Repository<T extends Entity> { private items: T[] = []; add(item: T): void { this.items.push(item); } findById(id: number): T | undefined { return this.items.find(item => item.id === id); } findAll(): T[] { return [...this.items]; } update(id: number, updates: Partial<T>): boolean { const item = this.findById(id); if (item) { Object.assign(item, updates); return true; } return false; } delete(id: number): boolean { const index = this.items.findIndex(item => item.id === id); if (index !== -1) { this.items.splice(index, 1); return true; } return false; } count(): number { return this.items.length; } } // Usage with specific entity types interface User extends Entity { name: string; email: string; } interface Product extends Entity { name: string; price: number; } const userRepo = new Repository<User>(); userRepo.add({ id: 1, name: "Alice", email: "alice@example.com" }); userRepo.add({ id: 2, name: "Bob", email: "bob@example.com" }); const user = userRepo.findById(1); console.log(user); // { id: 1, name: "Alice", email: "alice@example.com" } userRepo.update(1, { email: "newalice@example.com" }); console.log(userRepo.findById(1)?.email); // "newalice@example.com" const productRepo = new Repository<Product>(); productRepo.add({ id: 1, name: "Laptop", price: 999 }); console.log(productRepo.count()); // 1
Tip: The repository pattern with generics is excellent for creating reusable data access layers. It provides type safety while reducing code duplication across different entity types.

Abstract Static Methods (TypeScript 4.2+)

TypeScript 4.2 introduced the ability to declare abstract static methods in abstract classes:

// Abstract class with static abstract method abstract class DatabaseModel { abstract id: number; // Instance abstract method abstract save(): Promise<void>; // Static abstract method static abstract tableName: string; static abstract findById(id: number): Promise<any>; // Concrete static method static async findAll<T extends DatabaseModel>( this: typeof DatabaseModel & { tableName: string; new(): T } ): Promise<T[]> { console.log(`Finding all from ${this.tableName}`); // Database query logic here return []; } } class User extends DatabaseModel { static tableName = "users"; constructor( public id: number, public name: string, public email: string ) { super(); } async save(): Promise<void> { console.log(`Saving user ${this.name} to ${User.tableName}`); } static async findById(id: number): Promise<User | null> { console.log(`Finding user by id ${id} from ${this.tableName}`); // Database query logic return null; } } class Product extends DatabaseModel { static tableName = "products"; constructor( public id: number, public name: string, public price: number ) { super(); } async save(): Promise<void> { console.log(`Saving product ${this.name} to ${Product.tableName}`); } static async findById(id: number): Promise<Product | null> { console.log(`Finding product by id ${id} from ${this.tableName}`); return null; } } // Usage const user = new User(1, "Alice", "alice@example.com"); await user.save(); // "Saving user Alice to users" const foundUser = await User.findById(1); // "Finding user by id 1 from users" const allUsers = await User.findAll(); // "Finding all from users"

Protected Constructors

Protected constructors prevent direct instantiation while allowing subclass instantiation:

// Singleton pattern with protected constructor class Logger { private static instance: Logger; protected constructor() { // Protected constructor prevents: new Logger() } static getInstance(): Logger { if (!Logger.instance) { Logger.instance = new Logger(); } return Logger.instance; } log(message: string): void { console.log(`[LOG] ${message}`); } } // Cannot instantiate directly // const logger = new Logger(); // Error: constructor is protected // Must use static method const logger1 = Logger.getInstance(); const logger2 = Logger.getInstance(); console.log(logger1 === logger2); // true (same instance) // But can extend with protected constructor class ConsoleLogger extends Logger { constructor() { super(); // Can call protected constructor } log(message: string): void { console.log(`[CONSOLE] ${message}`); } } const consoleLogger = new ConsoleLogger(); // OK in subclass consoleLogger.log("Hello"); // "[CONSOLE] Hello"
Warning: Singleton patterns should be used sparingly. They can make testing difficult and create hidden dependencies. Consider dependency injection or other patterns when possible.

Method Overloading

TypeScript supports method overloading through function signatures:

class DataParser { // Overload signatures parse(data: string): object; parse(data: string[]): object[]; parse(data: object): string; // Implementation signature (must be compatible with all overloads) parse(data: string | string[] | object): object | object[] | string { if (typeof data === "string") { return JSON.parse(data); } else if (Array.isArray(data)) { return data.map(item => JSON.parse(item)); } else { return JSON.stringify(data); } } } const parser = new DataParser(); const obj = parser.parse('{"name":"Alice"}'); // Type: object console.log(obj); // { name: "Alice" } const arr = parser.parse(['{"id":1}', '{"id":2}']); // Type: object[] console.log(arr); // [{ id: 1 }, { id: 2 }] const str = parser.parse({ name: "Bob" }); // Type: string console.log(str); // '{"name":"Bob"}' // Constructor overloading class Point { constructor(x: number, y: number); constructor(coords: { x: number; y: number }); constructor(xOrCoords: number | { x: number; y: number }, y?: number) { if (typeof xOrCoords === "number") { this.x = xOrCoords; this.y = y!; } else { this.x = xOrCoords.x; this.y = xOrCoords.y; } } constructor(public x: number, public y: number) {} } const point1 = new Point(10, 20); const point2 = new Point({ x: 10, y: 20 });

Private Field Names (ES2022)

TypeScript supports ECMAScript private fields using the # prefix, which provides runtime privacy (unlike TypeScript's private keyword which is compile-time only):

class BankAccount { // ECMAScript private field (runtime private) #balance: number; // TypeScript private field (compile-time only) private accountNumber: string; constructor(initialBalance: number) { this.#balance = initialBalance; this.accountNumber = this.generateAccountNumber(); } deposit(amount: number): void { this.#balance += amount; } getBalance(): number { return this.#balance; } // Private method with # syntax #validateAmount(amount: number): boolean { return amount > 0 && amount <= this.#balance; } withdraw(amount: number): boolean { if (this.#validateAmount(amount)) { this.#balance -= amount; return true; } return false; } private generateAccountNumber(): string { return Math.random().toString(36).substr(2, 9); } } const account = new BankAccount(1000); account.deposit(500); console.log(account.getBalance()); // 1500 // account.#balance; // Syntax error - private field // account.accountNumber; // TypeScript error - private property // The difference: // - TypeScript private: erased at runtime, only enforced at compile time // - ECMAScript #private: truly private at runtime, cannot be accessed

Static Blocks (TypeScript 4.4+)

Static blocks allow complex static initialization logic:

class Configuration { static apiKey: string; static endpoint: string; static maxRetries: number; // Static block for initialization static { // Complex initialization logic const env = process.env.NODE_ENV || "development"; if (env === "production") { this.apiKey = process.env.PROD_API_KEY || ""; this.endpoint = "https://api.example.com"; this.maxRetries = 5; } else { this.apiKey = "dev-key-12345"; this.endpoint = "http://localhost:3000"; this.maxRetries = 3; } console.log(`Configuration loaded for ${env}`); } // Multiple static blocks are executed in order static { console.log("Additional initialization"); } static getConfig() { return { apiKey: this.apiKey, endpoint: this.endpoint, maxRetries: this.maxRetries }; } } // Static blocks run when the class is evaluated console.log(Configuration.getConfig());
Exercise: Create a generic Cache<K, V> class with a this return type for method chaining. Implement methods set(key: K, value: V), get(key: K), has(key: K), delete(key: K), and clear(). Add a mixin Timestamped that tracks when each entry was added. Extend the cache with a TTLCache subclass that automatically removes expired entries. Use method overloading for the set method to accept optional TTL parameter.

Decorator Patterns (Experimental)

While decorators are still experimental in TypeScript, they provide a powerful way to add metadata and modify class behavior:

// Note: Enable "experimentalDecorators" in tsconfig.json // Class decorator function sealed(constructor: Function) { Object.seal(constructor); Object.seal(constructor.prototype); } // Method decorator function log( target: any, propertyKey: string, descriptor: PropertyDescriptor ) { const originalMethod = descriptor.value; descriptor.value = function(...args: any[]) { console.log(`Calling ${propertyKey} with args:`, args); const result = originalMethod.apply(this, args); console.log(`Result:`, result); return result; }; return descriptor; } // Property decorator function readonly(target: any, propertyKey: string) { Object.defineProperty(target, propertyKey, { writable: false }); } @sealed class Calculator { @readonly version = "1.0"; @log add(a: number, b: number): number { return a + b; } @log multiply(a: number, b: number): number { return a * b; } } const calc = new Calculator(); calc.add(2, 3); // Calling add with args: [2, 3] // Result: 5 calc.multiply(4, 5); // Calling multiply with args: [4, 5] // Result: 20
Note: Decorators are currently a Stage 3 TC39 proposal. TypeScript's experimental decorator support differs slightly from the proposal. Always check the latest TypeScript documentation for decorator usage.

Summary

Advanced class patterns in TypeScript enable sophisticated object-oriented designs. Mixins provide composition, the this type enables fluent interfaces, generic constraints create reusable abstractions, and features like protected constructors, method overloading, and private fields offer fine-grained control. These patterns are essential for building scalable, maintainable enterprise applications.