TypeScript Design Patterns
Design patterns are proven solutions in software development that help us write maintainable and extensible code.
TypeScript's type system enables many classic design patterns to be implemented in a type-safe manner.
SVG Diagram: Design Pattern Classification
Why Do We Need Design Patterns?
Design patterns are code organization experience summarized by predecessors, and can solve common software design problems.
Using design patterns makes code easier to understand and maintain, and also facilitates team collaboration.
TypeScript's type system makes these patterns more robust, and errors can be discovered at compile time.
Concept:Design patterns are reusable solutions to common problems in software design, and are a summary of code design experience.
Singleton Pattern (Singleton)
Ensure a class has only one instance and provide a global access point.
Example
class Singleton {
// Store the singleton instance
private static instance: Singleton;
private static _data: string = "";
// Private constructor to prevent external instantiation
private constructor() {}
// Static method to get the singleton instance
public static getInstance(): Singleton {
if (!Singleton.instance) {
Singleton.instance = new Singleton();
}
return Singleton.instance;
}
// Set data
public setData(data: string): void {
Singleton._data = data;
}
// Get data
public getData(): string {
return Singleton._data;
}
}
// Test the singleton pattern
const instance1 = Singleton.getInstance();
const instance2 = Singleton.getInstance();
// Verify it is the same instance
console.log("Is same instance: " + (instance1 === instance2));
instance1.setData("Hello Singleton");
console.log("Data: " + instance2.getData());
Output:
是同一实例: true 数据: Hello Singleton
Private constructor:By setting the constructor to private, it prevents external instantiation using `new`.
Factory Pattern (Factory)
Use a generic factory to create type-safe object instances.
Example
interface Product {
name: string;
price: number;
getDescription(): string;
}
// Concrete product: electronics
class ElectronicProduct implements Product {
constructor(
public name: string,
public price: number,
public warranty: number
) {}
getDescription(): string {
return `${this.name} - ¥${this.price} (warranty${this.warranty}year)`;
}
}
// Concrete product: clothing
class ClothingProduct implements Product {
constructor(
public name: string,
public price: number,
public size: string
) {}
getDescription(): string {
return `${this.name} - ¥${this.price} (size: ${this.size})`;
}
}
// Factory class
class ProductFactory {
// Generic factory method
static create<T extends Product>(
type: new (...args: any[]) => T,
...args: any[]
): T {
return new type(...args);
}
}
// Use the factory to create products
const laptop = ProductFactory.create(ElectronicProduct, Laptop, 5999, 2);
const shirt = ProductFactory.create(ClothingProduct, T-shirt, 199, "L");
console.log(laptop.getDescription());
console.log(shirt.getDescription());
Generic factory:Use generic constraints to ensure a concrete product type is returned.
Decorator Pattern (Decorator)
Use decorators to dynamically add functionality to objects.
Example
interface Coffee {
getCost(): number;
getDescription(): string;
}
// Basic coffee implementation
class SimpleCoffee implements Coffee {
getCost(): number {
return 10;
}
getDescription(): string {
return Coffee;
}
}
// Decorator base class
abstract class CoffeeDecorator implements Coffee {
constructor(protected coffee: Coffee) {}
getCost(): number {
return this.coffee.getCost();
}
getDescription(): string {
return this.coffee.getDescription();
}
}
// Milk decorator
class MilkDecorator extends CoffeeDecorator {
getCost(): number {
return this.coffee.getCost() + 2;
}
getDescription(): string {
return this.coffee.getDescription() + ", Milk";
}
}
// Sugar decorator
class SugarDecorator extends CoffeeDecorator {
getCost(): number {
return this.coffee.getCost() + 1;
}
getDescription(): string {
return this.coffee.getDescription() + ", Sugar";
}
}
// Use decorator
let coffee: Coffee = new SimpleCoffee();
console.log(coffee.getDescription() + " - ¥" + coffee.getCost());
coffee = new MilkDecorator(coffee);
console.log(coffee.getDescription() + " - ¥" + coffee.getCost());
coffee = new SugarDecorator(coffee);
console.log(coffee.getDescription() + " - ¥" + coffee.getCost());
Decorator:It can dynamically add new functionality without modifying the original class, and is an experimental feature of TypeScript.
Observer Pattern (Observer)
Define a one-to-many dependency between objects; when an object's state changes, all dependents are notified.
Example
interface Observer {
update(message: string): void;
}
// Subject interface
interface Subject {
attach(observer: Observer): void;
detach(observer: Observer): void;
notify(): void;
}
// Concrete subject: message center
class MessageCenter implements Subject {
private observers: Observer[] = [];
private message: string = "";
// Add observer
attach(observer: Observer): void {
this.observers.push(observer);
}
// Remove observer
detach(observer: Observer): void {
const index = this.observers.indexOf(observer);
if (index > -1) {
this.observers.splice(index, 1);
}
}
// Notify all observers
notify(): void {
for (const observer of this.observers) {
observer.update(this.message);
}
}
// Publish message
publish(message: string): void {
this.message = message;
console.log("Publish message: " + message);
this.notify();
}
}
// Concrete observer: user
class UserObserver implements Observer {
constructor(public name: string) {}
update(message: string): void {
console.log(`[${this.name}]receivesMessage: ${message}`);
}
}
// Use the observer pattern
const center = new MessageCenter();
const user1 = new UserObserver("User A");
const user2 = new UserObserver("User B");
center.attach(user1);
center.attach(user2);
center.publish("New feature is online!");
Decoupling:The observer pattern implements loose coupling between subjects and observers.
Strategy Pattern (Strategy)
Define a series of algorithms, encapsulate them one by one, and make them interchangeable.
Example
interface PaymentStrategy {
pay(amount: number): void;
}
// WeChat Pay strategy
class WechatPayStrategy implements PaymentStrategy {
pay(amount: number): void {
console.log(`Using WeChat Pay ¥${amount}`);
}
}
// Alipay strategy
class AlipayStrategy implements PaymentStrategy {
pay(amount: number): void {
console.log(`Using Alipay ¥${amount}`);
}
}
// Bank card strategy
class CardPayStrategy implements PaymentStrategy {
pay(amount: number): void {
console.log(`Using Bank Card ¥${amount}`);
}
}
// Payment context
class PaymentContext {
private strategy: PaymentStrategy;
constructor(strategy: PaymentStrategy) {
this.strategy = strategy;
}
// Set payment strategy
setStrategy(strategy: PaymentStrategy): void {
this.strategy = strategy;
}
// Execute payment
pay(amount: number): void {
this.strategy.pay(amount);
}
}
// Use the strategy pattern
const context = new PaymentContext(new WechatPayStrategy());
context.pay(100);
context.setStrategy(new AlipayStrategy());
context.pay(200);
context.setStrategy(new CardPayStrategy());
context.pay(300);
Algorithm switching:The strategy pattern can switch algorithms at runtime, providing great flexibility.
Dependency Injection
Inject dependencies through the constructor; it is one of the most commonly used patterns in TypeScript.
Example
interface Logger {
log(message: string): void;
}
interface Storage {
save(key: string, data: any): void;
}
// Concrete service implementation
class ConsoleLogger implements Logger {
log(message: string): void {
console.log("[Log]: " + message);
}
}
class LocalStorage implements Storage {
save(key: string, data: any): void {
console.log(`Save ${key}: ${JSON.stringify(data)}`);
localStorage.setItem(key, JSON.stringify(data));
}
}
// Service using dependency injection
class UserService {
constructor(
private logger: Logger,
private storage: Storage
) {}
createUser(name: string): void {
const user = { name, createdAt: new Date() };
this.logger.log("Create user: " + name);
this.storage.save("user", user);
}
}
// Inject dependency
const logger = new ConsoleLogger();
const storage = new LocalStorage();
const userService = new UserService(logger, storage);
userService.createUser("Alice");
Dependency inversion:Dependency injection ensures that high-level modules do not depend on low-level modules, but rather on abstract interfaces.
Builder Pattern (Builder)
Separate the construction of a complex object from its representation, so that the same construction process can create different representations.
Example
interface Builder<T> {
build(): T;
}
// Complex object: user config
interface UserConfig {
name: string;
email: string;
age?: number;
role?: string;
theme?: string;
}
// User config builder
class UserConfigBuilder implements Builder<UserConfig> {
private config: Partial<UserConfig> = {};
setName(name: string): this {
this.config.name = name;
return this;
}
setEmail(email: string): this {
this.config.email = email;
return this;
}
setAge(age: number): this {
this.config.age = age;
return this;
}
setRole(role: string): this {
this.config.role = role;
return this;
}
setTheme(theme: string): this {
this.config.theme = theme;
return this;
}
build(): UserConfig {
if (!this.config.name || !this.config.email) {
throw new Error("Name and email are required");
}
return this.config as UserConfig;
}
}
// Use the builder
const builder = new UserConfigBuilder();
const config = builder
.setName("Alice")
.setEmail("alice@example.com")
.setAge(25)
.setRole("admin")
.setTheme("dark")
.build();
console.log("User config:", JSON.stringify(config, null, 2));
Chained calls:The builder pattern supports chained calls, making code more concise and readable.
Notes
- Type safety:Use TypeScript's type system to ensure the safety of pattern implementations.
- Don't overdo it:Only use design patterns when truly needed.
- Keep it simple:Prefer simple solutions.
- Team consensus:Unify the use of design patterns within the team.
Best practice:Design patterns are tools, not dogma. Choose the pattern that fits the actual situation of the project.
Summary
Design patterns are important experience in code organization.
- Singleton pattern:Ensure a class has only one instance
- Factory pattern:Encapsulate the object creation process
- Decorator pattern:Dynamically add functionality
- Observer pattern:One-to-many dependency relationship
- Strategy pattern:Algorithms are replaceable
- Dependency injection:Decouple dependencies
Other ExtensionsSuggestion:Understanding and applying design patterns in practice can help you write more elegant and maintainable code.