Bridge Design Pattern in TypeScript (Part 1): Motivation & Applicability

Hooman Momtaheni
6 min readMay 29, 2024

--

Veresk Bridge

In this series of articles, I want to discuss the Bridge design pattern as given in the book “Design Patterns: Elements of Reusable Object-Oriented Software” using the same content structure. However, I try to demonstrate the tough areas with additional explanations and TypeScript examples.

Intent

Decouple an abstraction from its implementation so that the two can vary independently.

Also Known As

Handle/Body

Motivation

When an abstraction can have one of several possible implementations, the usual way to accommodate them is to use inheritance. An abstract class defines the interface to the abstraction, and concrete subclasses implement it in different ways. But this approach isn’t always flexible enough. Inheritance binds an implementation to the abstraction permanently, which makes it difficult to modify, extend, and reuse abstractions and implementations independently.

Consider the implementation of a portable Window abstraction in a user interface toolkit. This abstraction should enable us to write applications that work on both the X Window System and IBM’s Presentation Manager (PM), for example. Using inheritance, we could define an abstract class Window and subclasses XWindow and PMWindowthat implement the Window interface for the different platforms.

// Abstract class defining the Window interface
abstract class Window {
abstract draw(): void;
}

// Concrete subclass for X Window System
class XWindow extends Window {
draw(): void {
console.log("Drawing window in X Window System");
}
}

// Concrete subclass for IBM's Presentation Manager
class PMWindow extends Window {
draw(): void {
console.log("Drawing window in IBM's Presentation Manager");
}
}

But this approach has two drawbacks:

1.It’s inconvenient to extend the Window abstraction to cover different kinds of windows or new platforms. Imagine an IconWindow subclass of Window that specializes the Window abstraction for icons. To support Icon Windows for both platforms, we have to implement two new classes, XIconWindow and PMIconWindow. Worse, we’ll have to define two classes for every kind of window. Supporting a third platform requires yet another new Window subclass for every kind of window.

Credit: Design Patterns: Elements of Reusable Object-Oriented Software

2. It makes client code platform-dependent (subclass-dependent). Whenever a client creates a window, it instantiates a concrete class that has a specific implementation. For example, creating an XWindow object binds the Window abstraction to the XWindow implementation, which makes the client code dependent on the XWindow implementation. This, in turn, makes it harder to port the client code to other platforms.
Clients should be able to create a window without committing to a concrete implementation (XWindow or PMWindow). Only the window implementation should depend on the platform on which the application runs. Therefore, client code should instantiate windows without mentioning specific platforms. For example, based on our XWindow or PMWindow classes client code could be:

//client code
const xWindow = new XWindow();
const pmWindow = new PMWindow();

xWindow.draw();
pmWindow.draw();

This example shows that the client code is platform-dependent, as it needs to know about different platform. This makes it difficult to port the client code to other platforms or extend it to support new platforms without modifying the client code itself.

The Bridge pattern addresses these problems by putting the Window abstraction and its implementation in separate class hierarchies. There is one class hierarchy for window interfaces (Window, IconWindow, TransientWindow) and a separate hierarchy for platform-specific window implementations, with Windowlmp as its root. The XWindowImp subclass, for example, provides an implementation based on the X Window System.

All operations on Window subclasses are implemented in terms of abstract operations from the Windowlmp interface. This decouples the window abstractions from the various platform-specific implementations. We refer to the relationship between Window and Windowlmp as a bridge, because it bridges the abstraction and its implementation, letting them vary independently.

// Window abstraction
abstract class Window {
abstract drawText(): void;
abstract drawRect(): void;
}

// Implementation interface
interface WindowImp {
devDrawText(): void;
devDrawLine(): void;
}
// X Window System implementation
class XWindowImp implements WindowImp {
devDrawText(): void {
console.log("X Window System: Drawing Text");
}

devDrawLine(): void {
console.log("X Window System: Drawing Line");
}
}

// IBM's Presentation Manager implementation
class PMWindowImp implements WindowImp {
devDrawText(): void {
console.log("PM: Drawing Text");
}

devDrawLine(): void {
console.log("PM: Drawing Line");
}
}
// Refined abstraction for IconWindow
class IconWindow extends Window {
constructor(public imp: WindowImp){
super()
}
drawText(): void {
this.imp.devDrawText();
}

drawRect(): void {
this.imp.devDrawLine();
this.imp.devDrawLine();
}

drawBorder(): void {
console.log("Drawing Icon Window Border");
this.drawRect();
}
}

// Refined abstraction for TransientWindow
class TransientWindow extends Window {
constructor(public imp: WindowImp){
super()
}
drawText(): void {
this.imp.devDrawText();
}

drawRect(): void {
this.imp.devDrawLine();
this.imp.devDrawLine();
}

drawCloseBox(): void {
console.log("Drawing Transient Window Close Box");
this.drawRect();
}
}
// Create instances of the concrete implementations
const xWindowImp = new XWindowImp();
const pmWindowImp = new PMWindowImp();

// Create instances of the refined abstractions using different implementations
const iconWindowX = new IconWindow(xWindowImp);
const transientWindowPM = new TransientWindow(pmWindowImp);

// Use the window objects
iconWindowX.drawText();
iconWindowX.drawRect();
iconWindowX.drawBorder();

transientWindowPM.drawText();
transientWindowPM.drawRect();
transientWindowPM.drawCloseBox();

Note that the Bridge pattern, by itself, does address client code portability to a significant extent by decoupling the abstraction from its implementation. However, it doesn’t fully eliminate the need for the client code to deal with platform-specific implementations unless combined with other patterns or techniques such as dependency injection or configuration management.

Applicability

Use the Bridge pattern when:

· you want to avoid a permanent binding between an abstraction and its implementation. This might be the case, for example, when the implementation must be selected or switched at run-time.

· both the abstractions and their implementations should be extensible by subclassing. In this case, the Bridge pattern lets you combine the different abstractions and implementations and extend them independently.

· changes in the implementation of an abstraction should have no impact on clients; that is, their code should not have to be recompiled.

· you have a proliferation of classes as shown earlier in the first Motivation diagram. Such a class hierarchy indicates the need for splitting an object into two parts. James Rumbaugh uses the term “nested generalizations” in the book “Object-Oriented Modeling and Design” to refer to such class hierarchies.

· you want to share an implementation among multiple objects (perhaps using reference counting*), and this fact should be hidden from the client.

*Reference counting is a technique where each object keeps track of how many references point to it. When a new reference is created, the count is incremented. When a reference is removed, the count is decremented. When the count reaches zero, the object can be safely deleted or cleaned up because no one is using it. For most JavaScript and TypeScript applications, the built-in garbage collection mechanisms are sufficient and efficient, making manual reference counting unnecessary.

Example:

// Implementor interface
interface WindowImp {
drawWindow(): void;
}

// Abstraction
abstract class Window {
abstract draw(): void;
}

// Refined Abstraction
class ApplicationWindow extends Window {
constructor(private imp: WindowImp){
super()
}

draw(): void {
this.imp.drawWindow();
}
}

// Reference counting shared implementation
class SharedWindowImp implements WindowImp {
private static instance: SharedWindowImp | null;
private refCount: number = 0;

private constructor() {}

static getInstance(): SharedWindowImp {
if (!SharedWindowImp.instance) {
SharedWindowImp.instance = new SharedWindowImp();
}
SharedWindowImp.instance.refCount++;
return SharedWindowImp.instance;
}

drawWindow(): void {
console.log("Drawing window using shared implementation");
}

release(): void {
if (--this.refCount === 0) {
SharedWindowImp.instance = null;
}
}
}

// Client code
function main() {
const sharedImp1 = SharedWindowImp.getInstance();
const sharedImp2 = SharedWindowImp.getInstance();

const appWindow1 = new ApplicationWindow(sharedImp1);
const appWindow2 = new ApplicationWindow(sharedImp2);

appWindow1.draw();
appWindow2.draw();

sharedImp1.release();
sharedImp2.release();
}

main();

In Part 2 we will talk about Bridge structure, consequences and implementation.

--

--

Hooman Momtaheni

Full Stack Web Application Developer | Admire foundations and structures | Helping companies have reliable web apps and motivated software development team