Bridge Design Pattern in TypeScript (Part 2): Structure, Consequences & Implementation

Hooman Momtaheni
5 min readMay 29, 2024

--

Veresk Bridge

In part 1, we explained the motivation & applicability of adopting the Bridge design pattern; in this section, we will explore its structure, consequences & implementation

Structure

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

Participants

Abstraction (Window)

- defines the abstraction’s interface.

- maintains a reference to an object of type Implementor.

Refined Abstraction (IconWindow)

- Extends the interface defined by Abstraction.

Implementor (Windowlmp)

- defines the interface for implementation classes. This interface doesn’t have to correspond exactly to Abstraction’s interface; in fact, the two interfaces can be quite different. Typically, the Implementor interface provides only primitive operations, and Abstraction defines higher-level operations based on these primitives.

Concrete Implementor (XWindowImp, PMWindowImp)

-implements the Implementor interface and defines its concrete implementation.

Collaborations

  • Abstraction forwards client requests to its Implementor object.

Consequences

The Bridge pattern has the following consequences:

1. Decoupling interface and implementation. An implementation is not bound permanently to an interface. The implementation of an abstraction can be configured at run-time. It’s even possible for an object to change its implementation at run-time.

Example:

// Implementor interface
interface Renderer {
renderCircle(radius: number): void;
}

// Abstraction
class Shape {
constructor(protected renderer: Renderer) {}

draw(): void {
throw new Error("Method 'draw' should be implemented");
}
}

// Concrete Implementors
class SVGRenderer implements Renderer {
renderCircle(radius: number): void {
console.log(`Rendering a circle with radius ${radius} in SVG format`);
}
}

class CanvasRenderer implements Renderer {
renderCircle(radius: number): void {
console.log(`Rendering a circle with radius ${radius} on a Canvas`);
}
}

// Concrete Abstraction
class Circle extends Shape {
constructor(renderer: Renderer, private radius: number) {
super(renderer);
}

draw(): void {
this.renderer.renderCircle(this.radius);
}
}

// Factory to get renderer based on type
class RendererFactory {
static getRenderer(type: string): Renderer {
switch (type) {
case 'SVG':
return new SVGRenderer();
case 'Canvas':
return new CanvasRenderer();
default:
throw new Error("Unknown renderer type");
}
}
}

// Assume we have set an environment variable RENDERER_TYPE
const rendererType: string = process.env.RENDERER_TYPE || 'SVG'; // Default to SVG if not set
const renderer: Renderer = RendererFactory.getRenderer(rendererType);
const circle: Circle = new Circle(renderer, 5);

circle.draw();

Furthermore, this decoupling encourages layering that can lead to a better structured system. The high-level part of a system only has to know about Abstraction and Implementor.

2. Improved extensibility. You can extend the Abstraction and Implementor hierarchies independently.

3. Hiding implementation details from clients. You can shield clients from implementation details, like the sharing of implementor objects and the accompanying reference count mechanism (As we explained in applicability section).

Implementation

Consider the following implementation issues when applying the Bridge pattern:

1. Only one Implementor. In situations where there’s only one implementation, creating an abstract Implementor class isn’t necessary. This is a degenerate case of the Bridge pattern; there’s a one-to-one relationship between Abstraction and Implementor. Nevertheless, this separation is still useful when a change in the implementation of a class must not affect its existing clients.

2. Creating the right Implementor object. How, when, and where do you decide which Implementor class to instantiate when there’s more than one?
If Abstraction knows about all concrete implementor classes, then it can instantiate one of them in its constructor; it can decide between them based on parameters passed to its constructor. If, for example, a collection class supports multiple implementations, the decision can be based on the size of the collection. A linked list implementation can be used for small collections and a hash table for larger ones.

interface CollectionImplementor<T> {
add(item: T): void;
getAll(): T[];
}
class LinkedListImplementor<T> implements CollectionImplementor<T> {
private items: T[] = [];

add(item: T): void {
this.items.push(item);
}

getAll(): T[] {
return this.items;
}
}

class HashTableImplementor<T> implements CollectionImplementor<T> {
private items: Map<number, T> = new Map();

add(item: T): void {
const key = this.items.size;
this.items.set(key, item);
}

getAll(): T[] {
return Array.from(this.items.values());
}
}
class Collection<T> {
private implementor: CollectionImplementor<T>;

constructor(size: number) {
if (size < 10) {
this.implementor = new LinkedListImplementor<T>();
} else {
this.implementor = new HashTableImplementor<T>();
}
}

add(item: T): void {
this.implementor.add(item);
}

getAll(): T[] {
return this.implementor.getAll();
}
}
const smallCollection = new Collection<number>(5);
smallCollection.add(1);
smallCollection.add(2);
console.log(smallCollection.getAll()); // Output: [1, 2]

const largeCollection = new Collection<number>(20);
largeCollection.add(1);
largeCollection.add(2);
console.log(largeCollection.getAll()); // Output: [1, 2]

Another approach is to choose a default implementation initially and change it later according to usage. For example, if the collection grows bigger than a certain threshold, then it switches its implementation to one that’s more appropriate for a large number of items.

class Collection<T> {
private implementor: CollectionImplementor<T>;
private threshold: number;

constructor(defaultSize: number, threshold: number) {
this.threshold = threshold;
this.implementor = defaultSize < threshold ? new LinkedListImplementor<T>() : new HashTableImplementor<T>();
}

add(item: T): void {
this.implementor.add(item);
if (this.implementor instanceof LinkedListImplementor && this.implementor.getAll().length >= this.threshold) {
this.switchToHashTableImplementor();
}
}

getAll(): T[] {
return this.implementor.getAll();
}

private switchToHashTableImplementor(): void {
const items = this.implementor.getAll();
this.implementor = new HashTableImplementor<T>();
for (const item of items) {
this.implementor.add(item);
}
}
}

It’s also possible to delegate the decision to another object altogether. In the Window/Windowlmp example (in Part 1), we can introduce a factory object (see Abstract Factory) whose sole duty is to encapsulate platform-specifics. The factory knows what kind of Windowlmp object to create for the platform in use; a Window simply asks it for a Windowlmp, and it returns the right kind. A benefit of this approach is that Abstraction is not coupled directly to any of the Implementor classes.

class ConcreteWindowFactory extends WindowFactory {
createWindow(type: string): Window {
if (type === 'X') {
return new XWindow();
} else if (type === 'PM') {
return new PMWindow();
} else {
throw new Error("Unknown window type");
}
}
}

3. Using multiple inheritance. You can use inheritance and implementation in TypeScript to create Bridge pattern.

// Interface for the Window
interface Window {
draw(): void;
}

class WindowBase {
commonMethod(): void {
console.log("Common functionality for all windows");
}
}

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

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

Related Patterns

An Abstract Factory can create and configure a particular Bridge.

The Adapter pattern is geared toward making unrelated classes work together. It is usually applied to systems after they’re designed. Bridge, on the other hand, is used up-front in a design to let abstractions and implementations vary independently.

--

--

Hooman Momtaheni

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