Skip to main content

Structural patterns in TypeScript

How to compose objects? It's an excellent question; Thinking about composition is, first and foremost, the right way to relate classes. Structural patterns guide us on how and when to wrap some instances in others.

Well-designed applications end up with dozens of classes. Some are like legacy-sealed stones, while others seem living creatures evolving. Sometimes we must deal with complex subsystems or add complexity to a simple buddy.

Structures



This article will show how to solve these situations with four structural patterns with examples in TypeScript. Let's dive in!

πŸŽ’ Prerequisites

To complete this tutorial, you will need the following:

  • A local development environment for TypeScript

  • Basic knowledge of Object-Oriented Programming

πŸ”Œ Adapter

Decouples third-party libraries (or legacy code) from the application

The problem

You depend on a third-party library but want to change her for a new one. Or the library has changed in ways you can support by now. Changing this library will be a nightmare if it spreads across the application.

A similar situation occurs when you have to work with several legacy systems. You can't change them but must integrate them with the new application.

// ❌ Bad example not using adapter
// ToDo: 🀒 an external library with proprietary format
export type ExternalEventData = {
  date: Date;
  host: string;
  device: string;
  severity: number;
  extension: string[];
};
export class ExternalEventService {
  createMessage(event: ExternalEventData): string[] {
    return [
      `Date: ${event.date}`,
      `Host: ${event.host}`,
      `Device: ${event.device}`,
      `Severity: ${event.severity}`,
      `Extension: ${event.extension.join(", ")}`,
    ];
  }
  writeMessage(message: string[]): string {
    const eventMessage = message.join("\n");
    console.log(eventMessage);
    return eventMessage;
  }
}
export class Client {
  // ! 🀒 client classes depending on concrete implementations
  private readonly logger: ExternalEventService;
  constructor() {
    // ! 🀒 client classes are coupled to the library
    this.logger = new ExternalEventService();
  }
  public doThings() {
    // ! 🀒 client classes are coupled to data format
    const event: ExternalEventData = {
      date: new Date(),
      host: "localhost",
      device: "myApp",
      severity: 0,
      extension: ["msg=Hello World"],
    };
    // ! 🀒 client classes are coupled to the interface
    const message = this.logger.createMessage(event);
    return this.logger.writeMessage(message);
  }
}

The solution with the Adapter pattern

The adapter pattern solves the issue by encapsulating the proprietary workflow in a class that implements the desired interface. The rest of the world only sees the desired interface.

// * ✅ Adapter solution

// * Define your desired Interface (or use an existing one)
export interface Logger {
  log(entry: LogEntry): string;
}
// * make an adapter implementing the desired interface
export class CommonEventAdapter implements Logger {
  // * 😏 The adapted class is wrapped in a private property
  private commonEventService: ExternalEventService = new ExternalEventService();
  // * 😏 The rest of the world only sees the desired interface
  log(entry: LogEntry): string {
    // * 😏 knowledge of the proprietary workflow is encapsulated in the adapter
    const commonEvent = this.adaptLogEntryToExternalEvent(entry);
    const commonEventMessage = this.commonEventService.createMessage(commonEvent);
    return this.commonEventService.writeMessage(commonEventMessage);
  }
  // * 😏 all the ugly stuff is hidden in the adapter
  private adaptLogEntryToExternalEvent(entry: LogEntry): ExternalEventData {
    return {
      date: entry.timestamp,
      host: "localhost",
      device: "myApp",
      severity: entry.category === "info" ? 0 : 1,
      extension: [`msg=${entry.message}`],
    };
  }
}

πŸŒ‰ Bridge

Allows several (usually two) complex subsystems to evolve independently

The problem

It is the generalization of the adapter pattern. It allows the decoupling of objects using interfaces (the bridge) between their abstractions so that the two can vary independently.

Abstraction and implementation are the main concepts you need to understand, having a role play like boss-employee. The abstraction is the high-level interface of a system, while the implementation is the concrete object doing hard work.

Both can evolve independently, only respecting the contract of their interfaces.

// ❌ Bad example not using a bridge
// implicit abstraction
export class Logger {
  // ! 🀒 The abstraction is coupled to the implementation
  log(message: string): void {
    const fileWriter = new FileWriter();
    fileWriter.write(message);
  }
}
// implicit implementor
export class FileWriter {
  write(message: string): void {
    console.log(`Writing message to file: ${message}`);
  }
}

The solution with the Bridge pattern

The bridge pattern solves the issue by decoupling the abstraction from the implementation. Both are interfaces connected by an abstract class wrapping implementor and exposing abstraction.

Structural patterns encourage composition, but we can find an exceptional use of inheritance in this case.

// ✅ Bridge solution
// IMPLEMENTOR
// * implementor interface
export interface Writer {
  write(message: string): void;
}
// * 😏 concrete (refined) implementor
export class FileWriter implements Writer {
  write(message: string): void {
    console.log(`Writing message to file: ${message}`);
  }
}
// * 😏 another concrete (refined) implementor
export class ApiWriter implements Writer {
  write(message: string): void {
    console.log(`Writing message to API: ${message}`);
  }
}
// ABSTRACTION
// * Abstraction interface
export interface Logger {
  readonly writer: Writer;
  log(message: string): void;
}
// * 😏 bridge abstraction
export abstract class LoggerBase implements Logger {
  // * 😏 wraps low-level interface
  writer: Writer;
  constructor(writer: Writer) {
    this.writer = writer;
  }
  // * 😏 exposes high-level interface
  abstract log(message: string): void;
}
// * 😏 concrete (refined) abstraction
export class LoggerApp extends LoggerBase {
  log(message: string): void {
    this.writer.write(message);
  }
}
// * 😏 another concrete (refined) abstraction
export class BrowserLoggerApp extends LoggerBase {
  log(message: string): void {
    this.writer.write(message + " " + navigator.userAgent);
  }
}

πŸ§‘πŸΌ‍🎨 Decorator

Adds functionality to a class without modifying it,

The problem

Having a legacy codebase, you may need to add new functionality to a class without modifying it. The solution comes in the form of a decorator.

// ! ❌ Bad example not using decorator

export class Logger {
  log(message: string): void {
    console.log(`Logging message: ${message}`);
  }
  // ToDo: 😱 add error logging functionality
  // you are forced to modify the original class
}

The solution with the Decorator pattern

The solution is to rewrap the original class into a decorator that implements the same interface and delegates the functionality. The new type should accommodate the new feature.

The decorator pattern heavily impacts the code, so use it cautiously.

// ✅ Decorator solution
// * original class not modified
export class Logger {
  log(message: string): void {
    console.log(`Logging message: ${message}`);
  }
}

// * generate an interface for the current functionality
export interface Log {
  log(message: string): void;
}

// * generate an interface for the new functionality
export interface ErrorLog {
  errorLog(error: Error): void;
}

// * Create a decorator class that implements the interface by wrapping the original class
export class LoggerDecorator implements Log, ErrorLog {
  // * The decorator wraps a reference to the original class
  private logger: Logger = new Logger();

  // * The decorator class delegates the original functionality to the original class
  log(message: string): void {
    // 😏 * could change the functionality if needed
    this.logger.log(message);
  }
  // * 😏 The decorator class adds new functionality
  errorLog(error: Error): void {
    console.log(`Logging error: ${error.message}`);
  }
}

🧱 Facade

Provides a simple interface to a set of complex interfaces in a subsystem.

The problem

When you find an object with too many dependencies, it is a sign that you need to use a facade. For example, there is a legacy codebase with much complexity, and you need to simplify it.

// ❌ Bad example not using a Facade
export class Application {
  // ToDo : 🀒 too many dependencies
  private writer = new Writer();
  private formatter = new Formatter();
  private authService = new AuthService();
  doThings(): void {
    // ToDo : 🀒 too much coupling and knowledge of the subsystems
    const user = this.authService.getUser();
    const logMessage = this.formatter.format("Doing things", user);
    this.writer.write(logMessage);
  }
}

The solution with the Facade pattern

You can use a facade to isolate the client from the subsystem, to simplify or hide the details of the subsystem. Using this pattern, you are delegating (i.e., segregating) responsibilities to the facade.

Some facades are created to solve a use case, while others are generic simplifications many clients can use.

// ✅ Facade solution
// * Facade class
export class Logger {
  private writer = new Writer();
  private formatter = new Formatter();
  private authService = new AuthService();
  log(message: string): void {
    // * 😏 The complexity of the subsystem is hidden from the client code
    const user = this.authService.getUser();
    const logMessage = this.formatter.format(message, user);
    this.writer.write(logMessage);
  }
}
export class Application {
  // reduce the number of dependencies
  private logger = new Logger();
  doThings(): void {
    // * 😏 the client code does his job
    this.logger.log("Doing things");
  }
}

Here you have a list of other Structural Patterns and their cases of use:

πŸ‘” Proxy

Provides a placeholder for a wrapper object to control the access to a target.

A proxy wrapper controls access to the original object, allowing you to perform something before or after the request.

πŸͺΆ Flyweight

Reduces the cost of creating and manipulating a large number of similar objects.

The flyweight pattern reduces the memory footprint by sharing data between similar objects. The wrapper saves the transmitted data acting like a cache.

🧰 Composite

Wraps a group of objects into a single object.

The best example of the composite pattern is the DOM tree. The DOM tree is a composite of nodes, and each node is a composite of other nodes. An invoice is another example of a composite of line items, payments, and addresses.

πŸŒ… Conclusion

When you go to create structures wrapping objects inside objects, you will find yourself in certain situations repeatedly. There are proven solutions to those problems, called structural patterns. In this article, I showed you their implementation in TypeScript. As a cheat sheet, I remind you of the pattern's names and their use cases.

  • πŸ”Œ Adapter: Decouples third-party libraries (or legacy code) from the application

  • πŸŒ‰ Bridge: Allows several (usually two) complex subsystems to evolve independently

  • πŸ§‘πŸΌ‍🎨 Decorator: Adds functionality to a class without modifying it

  • 🧱 Facade: Provides a simple interface to a set of complex interfaces in a subsystem

  • πŸ‘” Proxy: Provides a placeholder for another object to control access

  • πŸͺΆ Flyweight: Reduces the cost of creating and manipulating a large number of similar objects

  • 🧰 Composite: Wraps a group of objects into a single object

I hope you enjoyed it and learned something new. If you have any questions or suggestions, please leave a comment below.

learn, code, enjoy, repeat

Alberto Basalo

Popular posts from this blog

Fine-tune ESLint rules to write better TypeScript

Writing clean code is a lot easier with the right tools well configured. ESLint is a static analyzer for JavaScript programs. So what does that mean and what can it do for my TypeScript code? First things first, by static it means that you don't need to run or even compile a program to parse it. Analyzing means that it checks your code searching for flaws or bad metrics that can cause problems in the long run. The ESLint is a linter that runs before the compiler. Witch runs before your automated tests and the end-user. Is your first line of defense and should be as integrated with your editor as possible. For the details about integrating ESLint and VSCode, you can read my article to configure basic extensions But ESLint would not be so popular if it was not extensible and configurable . And there is the real power and complexity. In this article, I'll walk you through

10 commandments to naming and writing clean code with TypeScript

A code writer is like any other writer; writes to be read and understood . To do so it must obey certain laws. They are so important that must be followed religiously. Being a good writer is an art. But you can be a better programmer by knowing and applying a set of standards. In this guide, I will show you TypeScript samples for the  10 rules of clean naming . When you're finished, you'll be able to write heavenly code.Let there be code naming conventions 1️⃣ Be Descriptive Add value without being repetitive. Context is key. Prefer clarity over saving space. We are not in the ‘90s’ anymore. // ❌ const width = 5 ; class Image { imageWidth = width ; } // ✅ const imageWidth = 5 ; class Image { width = imageWidth; } 2️⃣ Be Meaningful Use the same word for the same concept. Create a dictionary for business and infrastructure common words. // ❌ getClient () {} readProvider () {} postCustomer () {} // ✅ getClient ()

How to configure VSCode to code better TypeScript

Writing a more readable code is your first goal as a programmer. This initial Visual Studio Code setup makes your job easier. Any good worker must know and customize his tools for the job at hand. With minor tweaks, VSCode is the perfect tool to write TypeScript . There are plenty of guides to configure VSCode . Most of them include a lot of extensions. But almost none starts from the very beginning, with no extension at all. I am an enthusiast of writing clean code , so I do my best to promote and facilitate this goal, and I am sure that this can help you to write better code. In this post, you will learn how to adapt VS Code to write better TypeScript code, even without any extension. And above all, you will get tips to adopt good habits and detect programming vices. πŸŽ’ Prerequisites To complete this tutorial, you will need: A local copy of Visual Studi