Adapter Design Pattern in TypeScript (Part 4): Implementation & Related Patterns

Hooman Momtaheni
4 min readMay 25, 2024

--

Part 1 is about intent and motivation, part 2 is about applicability and structure, part 3 is about the consequences of the adapter design pattern, and now we’ll speak about its implementation.

Implementation

Although the implementation of Adapter is usually straightforward, here are some issues to keep in mind:

  1. Implementing class adapters in TypeScript. In a TypeScript implementation of a class adapter by using mixin, Adapter would inherit from Target. Thus, Adapter would be a subtype of Target but not of Adaptee.
  2. Pluggable adapters. Let’s look at three ways to implement pluggable adapters for the TreeDisplay widget described earlier, which can lay out and display a hierarchical structure automatically.
    The first step, which is common to all three of the implementations discussed here, is to find a “narrow” interface for Adaptec, that is, the smallest subset of operations that lets us do the adaptation. A narrow interface consisting of only a couple of operations is easier to adapt than an interface with dozens of operations. For TreeDisplaywe discussed in Part 3, the adaptee is any hierarchical structure. A minimalist interface might include two operations, one that defines how to present a node in the hierarchical structure graphically, and another that retrieves the node’s children.
    The narrow interface leads to three implementation approaches:

a. Using abstract operations. Define corresponding abstract operations for the narrow Adaptee interface in the TreeDisplay class. Subclasses that are implemented by the client, must implement the abstract operations, and adapt the hierarchically structured object. For example, a DirectoryTreeDisplay subclass will implement these operations by accessing the directory structure.

abstract class TreeDisplay {
abstract getChildren(node: any): any[];
abstract createGraphicNode(node: any): void;
abstract display(): void;

buildTree(node: any): void {
this.createGraphicNode(node);
const children = this.getChildren(node);
children.forEach(child => this.buildTree(child));
}
}

class FileSystemEntity {
constructor(public name: string, public children: FileSystemEntity[] = []) {}
}

class DirectoryTreeDisplay extends TreeDisplay {
private graphicNodes: any[] = [];

getChildren(node: FileSystemEntity): FileSystemEntity[] {
return node.children;
}

createGraphicNode(node: FileSystemEntity): void {
console.log("Creating graphic node for directory: " + node.name);
this.graphicNodes.push(node);
}

display(): void {
console.log("Displaying tree with graphic nodes: ");
this.graphicNodes.forEach(node => {
console.log("Graphic node for: " + node.name);
});
}
}

// Example usage
const root = new FileSystemEntity("root", [
new FileSystemEntity("bin"),
new FileSystemEntity("usr", [
new FileSystemEntity("local"),
new FileSystemEntity("bin")
]),
new FileSystemEntity("etc")
]);

const directoryTreeDisplay = new DirectoryTreeDisplay();
directoryTreeDisplay.buildTree(root);
directoryTreeDisplay.display();
Credit: Design Patterns: Elements of Reusable Object-Oriented Software

DirectoryTreeDisplay specializes the narrow interface so that it can display directory structures made up of FileSystemEntity objects.

b. Using delegate objects. In this approach, TreeDisplay forwards requests for accessing the hierarchical structure to a delegate object. TreeDisplay can use a different adaptation strategy by substituting a different delegate. For example, suppose there exists a DirectoryBrowser that uses a TreeDisplay. DirectoryBrowser might make a good delegate for adapting TreeDisplay to the hierarchical directory structure.

interface TreeDelegate {
getChildren(node: any): any[];
createGraphicNode(node: any): void;
}

abstract class TreeDisplay {
constructor(public delegate: TreeDelegate) {}

buildTree(node: any): void {
this.delegate.createGraphicNode(node);
const children = this.delegate.getChildren(node);
children.forEach(child => this.buildTree(child));
}

display(): void {
// Implement display logic if needed.
}
}

class FileSystemEntity {
constructor(public name: string, public children: FileSystemEntity[] = []) {}
}
class DirectoryBrowser implements TreeDelegate {
private graphicNodes: any[] = [];

getChildren(node: FileSystemEntity): FileSystemEntity[] {
return node.children;
}

createGraphicNode(node: FileSystemEntity): void {
console.log("Creating graphic node for directory: " + node.name);
this.graphicNodes.push(node);
}

displayGraphicNodes(): void {
console.log("Displaying tree with graphic nodes: ");
this.graphicNodes.forEach(node => {
console.log("Graphic node for: " + node.name);
});
}
}
// Example usage
const root = new FileSystemEntity("root", [
new FileSystemEntity("bin"),
new FileSystemEntity("usr", [
new FileSystemEntity("local"),
new FileSystemEntity("bin")
]),
new FileSystemEntity("etc")
]);

const directoryBrowser = new DirectoryBrowser();
const treeDisplay = new class extends TreeDisplay {
constructor() {
super(directoryBrowser);
}
};

treeDisplay.buildTree(root);
directoryBrowser.displayGraphicNodes();

c. Parameterized adapters. In this approach, an adapter is parameterized with one or more blocks (also known as closures or lambda functions). These blocks encapsulate the adaptation logic for specific requests, allowing the adapter to adapt requests without the need for subclassing. A block can adapt a request, and the adapter can store a block for each individual request. In our example, this means TreeDisplay stores one block for converting a node into a GraphicNode and another block for accessing a node’s children.

// Define the TreeDisplay class
class TreeDisplay {
private getChildrenBlock: (node: any) => any[];
private createGraphicNodeBlock: (node: any) => GraphicNode;

constructor(getChildrenBlock: (node: any) => any[], createGraphicNodeBlock: (node: any) => GraphicNode) {
this.getChildrenBlock = getChildrenBlock;
this.createGraphicNodeBlock = createGraphicNodeBlock;
}

// Build and display the tree
buildTree(node: any): void {
const children = this.getChildrenBlock(node);
children.forEach(child => {
const graphicNode = this.createGraphicNodeBlock(child);
console.log("Displaying graphic node: ", graphicNode.name);
this.buildTree(child);
});
}
}

// Example usage
const treeRoot = { name: "root", children: [{ name: "child1" }, { name: "child2" }] };

// Create a TreeDisplay instance with adapter blocks
const directoryDisplay = new TreeDisplay(
// Block for getting children
(node: any) => node.children || [],

// Block for creating graphic node
(node: any) => ({ name: node.name })
);

// Build and display the tree
console.log("Building and displaying tree:");
directoryDisplay.buildTree(treeRoot);

This approach offers a convenient alternative to subclassing.

Related Patterns

Bridge has a structure similar to an object adapter, but Bridge has a different intent: It is meant to separate an interface from its implementation so that they can be varied easily and independently. An adapter is meant to change the interface of an existing object.

Decorator enhances another object without changing its interface. A decorator is thus more transparent to the application than an adapter is. As a consequence, Decorator supports recursive composition, which isn’t possible with pure adapters.

Proxy defines a representative or surrogate for another object and does not change its interface.

--

--

Hooman Momtaheni

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