Structural Patterns in TypeScript

Alberto Basalo
8 min readFeb 23, 2023

--

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.

Foto de Coline Beulin en Unsplash

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

Part of a series about Design Patterns with TypeScript.

πŸ”Œ Adapter

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

The problem

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);
}
}

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.

The solution with the Adapter pattern

// * βœ… Adapter solution
// * Define your desired Interface (or use an existing one)

export type LogCategory = "info" | "error" | "debug";
export type LogEntry = {
category: LogCategory;
message: string;
timestamp: Date;
};
export interface Logger {
log(entry: LogEntry): string;
}

// * make an adapter implementing the desired interface
export class ExternalEventServiceAdapter implements Logger {
// * 😏 The adapted class is wrapped in a private property
private adapted: ExternalEventService = new ExternalEventService();
// * 😏 The rest of the world only sees the desired interface
log(entry: LogEntry): string {
// * 😏 knowledge of proprietary workflow encapsulated in adapter
const commonEvent = this.adaptLogEntry2ExternalEvent(entry);
const commonEventMessage = this.adapted.createMessage(commonEvent);
// Todo: change the writer or make it configurable
return this.adapted.writeMessage(commonEventMessage);
}
// * 😏 all the ugly stuff is hidden in the adapter
private adaptLogEntry2ExternalEvent(entry: LogEntry): ExternalEventData {
return {
date: entry.timestamp,
host: "localhost",
device: "myApp",
severity: entry.category === "info" ? 0 : 1,
extension: [`msg=${entry.message}`],
};
}
}
function loggerFactory(): Logger {
// * 😏 can use any other adaptation
return new ExternalEventServiceAdapter();
}

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.

πŸŒ‰ 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
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 {
// * 😏 dependencies are hidden from the client code
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

Originally published at https://blog.albertobasalo.dev on February 23, 2023.

--

--

Alberto Basalo
Alberto Basalo

Written by Alberto Basalo

Advisor and instructor for developers. Keeping on top of modern technologies while applying proven patterns learned over the last 25 years. Angular, Node, Tests

No responses yet