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.