Design patterns play a crucial role in software development, providing proven solutions to common problems and promoting code organization and maintainability. When it comes to frontend development, TypeScript has emerged as a powerful tool, enhancing JavaScript with static typing and enabling developers to create robust and scalable applications. In this article, we will explore how to implement common OOP design patterns with Typescript.
Introduction to Design Patterns and TypeScript
Design patterns are reusable solutions to recurring problems in software design. They help developers avoid reinventing the wheel and adhere to best practices. TypeScript, a statically-typed superset of JavaScript, provides enhanced type checking and tooling support, making it an excellent choice for frontend development. TypeScript’s type system aids in catching errors early in the development process and improving code quality.
For an in-depth guide on Typescript visit here.
Let’s dive into some popular design patterns and see how they can be implemented with TypeScript.
Creational Patterns
Factory Design Pattern
The Factory Method pattern provides an interface for creating objects but allows subclasses to alter the type of objects that will be created. This pattern is particularly useful for creating instances of different classes based on a common interface or abstract class.
The main idea behind the Factory Design Pattern is to delegate the responsibility of creating objects to specialized classes called factories, rather than creating objects directly using constructors. The advantage of this is decoupling – making things less dependent on each other. Also, by centralizing the object creation logic in factories, you can manage object creation and configuration in one place, making maintenance and modifications easier.
interface Shape {
draw(): void;
}
class Circle implements Shape {
draw() {
console.log("Drawing a circle");
}
}
class Square implements Shape {
draw() {
console.log("Drawing a square");
}
}
abstract class ShapeFactory {
abstract createShape(): Shape;
}
class CircleFactory extends ShapeFactory {
createShape() {
return new Circle();
}
}
class SquareFactory extends ShapeFactory {
createShape() {
return new Square();
}
}
Singleton Design Pattern
The Singleton pattern ensures that a class has only one instance and provides a global point of access to that instance. TypeScript’s static type checking can prevent accidental creation of multiple instances.
The Singleton pattern is handy when you have a class that should have only one instance across your program. For example, a configuration manager, a logging system, or a database connection manager. These are things that you want to ensure are consistent and shared across different parts of your application.
class AppConfig {
private static instance: AppConfig;
private constructor() {
// Private constructor to prevent direct instantiation
}
static getInstance(): AppConfig {
if (!this.instance) {
this.instance = new AppConfig();
}
return this.instance;
}
}
Builder Design Pattern
The Builder pattern separates the construction of a complex object from its representation, allowing the same construction process to create different representations. TypeScript’s optional properties and type-checking can assist in creating well-defined builders.
The Builder pattern helps you create complex objects step by step. It separates the construction of a complex object from its representation, allowing you to create different variations of the same object without cluttering your code with tons of constructors or initialization methods.
class Product {
constructor(
public name: string,
public price: number,
public description: string
) {}
}
class ProductBuilder {
private name: string = "";
private price: number = 0;
private description: string = "";
withName(name: string) {
this.name = name;
return this;
}
withPrice(price: number) {
this.price = price;
return this;
}
withDescription(description: string) {
this.description = description;
return this;
}
build(): Product {
return new Product(this.name, this.price, this.description);
}
}
// Using the ProductBuilder to create a Product instance
const product = new ProductBuilder()
.withName("Example Product")
.withPrice(99.99)
.withDescription("This is an example product.")
.build();
console.log(product);
/*
Product {
name: 'Example Product',
price: 99.99,
description: 'This is an example product.'
}
*/
What is Method Chaining?
Method chaining allows you to call multiple methods on an object one after another, without having to repeatedly reference the object. Each method returns the modified object, so you can immediately call another method on the result.
To enable method chaining, each method in the builder class should return this
(the current instance of the builder) after performing the necessary operation. This allows the next method to be called on the same instance, and the chain continues.
Structural Patterns
Adapter Design Pattern
The Adapter pattern allows objects with incompatible interfaces to collaborate. TypeScript’s type system helps ensure compatibility between the adapter and the adapted.
The Adapter pattern allows you to integrate new functionality with existing systems without needing to change their code. It’s like having a language translator that makes two people with different languages communicate effectively.
// Adaptee: The existing MP3 player class
class Mp3Player {
playMp3(filename: string): void {
console.log(`Playing MP3 file: ${filename}`);
}
}
// Target: The interface expected by the client
interface AudioPlayer {
play(filename: string): void;
}
// Adapter: Adapter for playing FLAC files using the Mp3Player
class FlacAdapter implements AudioPlayer {
private mp3Player: Mp3Player;
constructor(mp3Player: Mp3Player) {
this.mp3Player = mp3Player;
}
play(filename: string): void {
console.log(`Converting FLAC to MP3 and playing: ${filename}`);
const convertedFilename = this.convertFlacToMp3(filename);
this.mp3Player.playMp3(convertedFilename);
}
private convertFlacToMp3(filename: string): string {
// Simulate the conversion process
const convertedFilename = filename.replace('.flac', '.mp3');
console.log(`Converted FLAC to MP3: ${convertedFilename}`);
return convertedFilename;
}
}
// Client code
const mp3Player = new Mp3Player();
const flacAdapter = new FlacAdapter(mp3Player);
const audioFiles: string[] = ['song1.mp3', 'song2.flac', 'song3.mp3'];
audioFiles.forEach(file => {
if (file.endsWith('.mp3')) {
mp3Player.playMp3(file);
} else if (file.endsWith('.flac')) {
flacAdapter.play(file);
} else {
console.log(`Unsupported audio format: ${file}`);
}
});
Decorator Design Pattern
The Decorator pattern attaches additional responsibilities to an object dynamically. TypeScript’s interfaces and classes can be easily extended to implement decorators.
In OOP, the Decorator pattern lets you add new behaviors or features to objects without changing their underlying structure.
In this example, the SimpleCoffee class represents a basic coffee. The MilkDecorator
and SugarDecorator
classes both implement the Coffee
interface and take an instance of another Coffee
object as a constructor parameter. They enhance the behavior of the coffee by adding milk or sugar, respectively.
interface Coffee {
cost(): number;
description(): string;
}
class SimpleCoffee implements Coffee {
cost() {
return 5;
}
description() {
return "Simple coffee";
}
}
class MilkDecorator implements Coffee {
constructor(private coffee: Coffee) {}
cost() {
return this.coffee.cost() + 2;
}
description() {
return `${this.coffee.description()} with milk`;
}
}
class SugarDecorator implements Coffee {
constructor(private coffee: Coffee) {}
cost() {
return this.coffee.cost() + 1;
}
description() {
return `${this.coffee.description()} with sugar`;
}
}
Composite Design Pattern
The Composite pattern composes objects into tree structures to represent part-whole hierarchies. TypeScript’s support for classes and inheritance simplifies the implementation of composite objects.
// Component
interface Employee {
getName(): string;
getRole(): string;
}
// Leaf
class Developer implements Employee {
constructor(private name: string) {}
getName(): string {
return this.name;
}
getRole(): string {
return 'Developer';
}
}
// Composite
class Manager implements Employee {
private team: Employee[] = [];
constructor(private name: string) {}
addEmployee(employee: Employee) {
this.team.push(employee);
}
getName(): string {
return this.name;
}
getRole(): string {
return 'Manager';
}
getTeamInfo(): string {
let info = `${this.name} - Manager\n`;
for (const employee of this.team) {
info += ` ${employee.getName()} - ${employee.getRole()}\n`;
}
return info;
}
}
// Client code
const john = new Developer('John');
const sara = new Developer('Sara');
const engineeringManager = new Manager('Engineering Manager');
engineeringManager.addEmployee(john);
engineeringManager.addEmployee(sara);
const ceo = new Manager('CEO');
ceo.addEmployee(engineeringManager);
console.log(ceo.getTeamInfo());
/*
CEO - Manager
Engineering Manager - Manager
*/
console.log(engineeringManager.getTeamInfo());
/*
Engineering Manager - Manager
John - Developer
Sara - Developer
*/
Behavioral Patterns
Observer Design Pattern
The Observer pattern defines a dependency between objects so that when one object changes state, all its dependents are notified and updated automatically. TypeScript’s type system helps ensure that observers and subjects are properly synchronized.
interface Observer {
update(data: any): void;
}
class ConcreteObserver implements Observer {
update(data: any) {
console.log(`Received update: ${data}`);
}
}
interface Subject {
attach(observer: Observer): void;
detach(observer: Observer): void;
notify(): void;
}
class ConcreteSubject implements Subject {
private observers: Observer[] = [];
private data: any;
attach(observer: Observer) {
this.observers.push(observer);
}
detach(observer: Observer) {
const index = this.observers.indexOf(observer);
if (index !== -1) {
this.observers.splice(index, 1);
}
}
notify() {
for (const observer of this.observers) {
observer.update(this.data);
}
}
setData(data: any) {
this.data = data;
this.notify();
}
}
If you are an experienced Javascript developer then you have probably used the Observer pattern without knowing. For instance, JavaScript’s built-in event handling mechanism, such as the addEventListener
function, is an implementation of the Observer pattern. DOM elements act as subjects, and event listeners act as observers. When an event occurs on the subject (e.g., a button click), all registered event listeners are notified and invoked.
Other examples include State Management in React using the Context API and the Intersection Observer API.
Strategy Design Pattern
The Strategy pattern defines a family of interchangeable algorithms and makes them easily swappable. TypeScript’s interfaces and type-checking contribute to a seamless integration of strategies.
interface PaymentStrategy {
pay(amount: number): void;
}
class CreditCardPayment implements PaymentStrategy {
constructor(private cardNumber: string) {}
pay(amount: number) {
console.log(`Paid ${amount} using credit card ${this.cardNumber}`);
}
}
class PayPalPayment implements PaymentStrategy {
constructor(private email: string) {}
pay(amount: number) {
console.log(`Paid ${amount} using PayPal account ${this.email}`);
}
}
class PaymentContext {
private paymentStrategy: PaymentStrategy;
constructor(paymentStrategy: PaymentStrategy) {
this.paymentStrategy = paymentStrategy;
}
makePayment(amount: number) {
this.paymentStrategy.pay(amount);
}
}
// Client code
const creditCardPayment = new CreditCardPayment('1234-5678-9012-3456');
const paypalPayment = new PayPalPayment('[email protected]');
const paymentContextCreditCard = new PaymentContext(creditCardPayment);
const paymentContextPayPal = new PaymentContext(paypalPayment);
paymentContextCreditCard.makePayment(100);
paymentContextPayPal.makePayment(50);
State Design Pattern
The State pattern allows an object to change its behavior when its internal state changes. This is achieved by separating the states into separate classes and allowing the object to switch between these states as needed.
TypeScript’s classes and type-checking help ensure that states and context classes are consistent.
In this example, the State Pattern is demonstrated using a simple example of a file that can be in either an “Open” state or a “Closed” state.
interface State {
handleRequest(): void;
}
class OpenState implements State {
handleRequest() {
console.log("File is open");
}
}
class ClosedState implements State {
handleRequest() {
console.log("File is closed");
}
}
class FileContext {
private state: State;
constructor() {
this.state = new ClosedState();
}
setState(state: State) {
this.state = state;
}
request() {
this.state.handleRequest();
}
}
// Client code
const fileContext = new FileContext();
fileContext.request(); // Output: File is closed
fileContext.setState(new OpenState());
fileContext.request(); // Output: File is open
fileContext.setState(new ClosedState());
fileContext.request(); // Output: File is closed
Benefits of Implementing OOP Design Patterns with Typescript
TypeScript enhances the implementation of design patterns in several ways:
- Type Safety: TypeScript’s static type checking prevents many common errors during development and refactoring, ensuring that patterns are implemented correctly.
- Code Readability: The explicit typing in TypeScript code improves readability, making patterns and their interactions more understandable.
- Tooling Support: TypeScript’s tooling, such as autocompletion and type inference, speeds up development when working with pattern-related classes and interfaces.
- Refactoring Confidence: Renaming classes, methods, or properties is less error-prone in TypeScript due to its type-aware nature.
Conclusion
Design patterns provide a systematic approach to solving common software design problems. Creational, structural, and behavioral patterns offer elegant solutions for creating objects, managing their relationships, and defining their interactions.