Adapter Design Pattern in TypeScript (Part 1): Intent & Motivation
In this series of articles, I plan to cover the adapter design pattern as described in the book “Design Patterns: Elements of Reusable Object-Oriented Software” using the same content structure. But I attempted to illustrate the tricky sections by more explanations and using TypeScript examples.
Intent
Convert the interface of a class into another interface clients expect. Adapter lets classes work together that couldn’t otherwise because of incompatible interfaces.
Also Known As
Wrapper
Motivation
Often, a toolkit class that’s designed for reuse isn’t actually reusable because its interface doesn’t align with the domain-specific interface required by an application. Take, for instance, a drawing editor that allows users to create and organize graphical elements such as lines, polygons, and text into images and diagrams. The central abstraction in this drawing editor is the graphical object, which has an editable shape and can render itself. This interface is specified by an abstract class called Shape
. For each type of graphical object, the editor defines a subclass of Shape
: LineShape
for lines, PolygonShape
for polygons, and so on.
Implementing classes for basic geometric shapes like LineShape
and PolygonShape
is straightforward because their drawing and editing capabilities are limited by nature. On the other hand, creating a TextShape
subclass that can both display and edit text is much more complex, due to the intricate screen updates and buffer management required for text editing. Meanwhile, a sophisticated class such as TextView
from an existing UI toolkit might already handle text display and editing effectively. Ideally, we want to reuse TextView
to implement TextShape
, but since TextView
was not designed with Shape classes in mind, TextView
and Shape objects cannot be used interchangeably.
abstract class Shape {
abstract BoundingBox(): { x: number, y: number, width: number, height: number };
abstract createManipulator(): void;
}
class TextView {
constructor(public text: string) {}
GetExtent(): { x: number, y: number, width: number, height: number } {
// Mock implementation of text extent calculation.
return { x: 0, y: 0, width: this.text.length * 7, height: 20 };
}
displayText(): void {
console.log("Displaying text: " + this.text);
}
// Other text-view related methods can be defined here.
}
To enable existing, unrelated classes like TextView
to function within an application that expects a different and incompatible interface, we face a dilemma. Modifying the TextView
class to conform to the Shape
interface isn’t feasible without access to the toolkit’s source code. Moreover, even with the source code, it wouldn’t be appropriate to alter TextView
to fit one application’s specific needs.
Instead, we can create TextShape to adapt the TextView
interface to match that of Shape
. This can be achieved in two ways:
(1) by inheriting from Shape
and implementing TextView
’s functionality or
(2) by incorporating a TextView
instance within TextShape
and implementing TextShape
to work with TextView
’s interface.
These two methods correspond to the class and object forms of the Adapter pattern. Thus, TextShape
serves as an adapter.
Example of (1) class form of Adapter pattern:
For implementing Adapter in this way in TypeScript that has not ability of multi-inheritance, we should use a mixin function (A mixin function is a design pattern used to create reusable chunks of behavior or functionality that can be added to multiple classes without using inheritance. Mixins allow for the combination of methods and properties from different sources, enabling developers to compose classes with shared functionality in a flexible and modular manner.):
function TextViewMixin<TBase extends new (...args: any[]) => {}>(Base: TBase) {
return class extends Base {
textView: TextView;
constructor(...args: any[]) {
super(...args);
this.textView = new TextView(args[0]);
}
displayText(): void {
this.textView.displayText();
}
GetExtent(): { x: number, y: number, width: number, height: number } {
return this.textView.GetExtent();
}
};
}
Since we can’t use Shape directly in the mixin, we create ConcreteShape
as an intermediary:
class ConcreteShape extends Shape {
private text: string;
constructor(text: string) {
super();
this.text = text;
}
BoundingBox(): { x: number, y: number, width: number, height: number } {
return { x: 0, y: 0, width: 100, height: 100 };
}
createManipulator(): void {
console.log("Creating manipulator for ConcreteShape");
}
}
Ultimately, we create our TextShape
class with mixin:
class TextShape extends TextViewMixin(ConcreteShape) {
constructor(text: string) {
super(text);
}
BoundingBox(): { x: number, y: number, width: number, height: number } {
return this.GetExtent();
}
createManipulator(): void {
console.log("Creating manipulator for TextShape");
}
draw(): void {
this.displayText();
}
}
Usage:
const shapes: Shape[] = [
new TextShape("Hello, world!"),
new TextShape("Adapter pattern example")
];
shapes.forEach(shape => {
shape.createManipulator();
const bounds = shape.BoundingBox();
console.log(`Bounds: x=${bounds.x}, y=${bounds.y}, width=${bounds.width}, height=${bounds.height}`);
});
Note that if instead of Shape
abstract class, we defined Shape as an interface, we didn’t need mixin and we could create adapter like this:
class TextShape extends TextView implements Shape{
//adapter operations
}
Example of (2) class form of Adapter pattern:
The object form of the Adapter pattern is easier to implement than the class form. Instead of using mixins, the adapter class directly holds an instance of the class it adapts (TextView
). The adapter class (TextShape
) implements the target interface (Shape
) and delegates the necessary method operations to the instance of the adaptee class (TextView
).
class TextShape extends Shape {
private textView: TextView;
constructor(text: string) {
super();
this.textView = new TextView(text);
}
BoundingBox(): { x: number, y: number, width: number, height: number } {
return this.textView.GetExtent();
}
createManipulator(): void {
console.log("Creating manipulator for TextShapeAdapter");
}
draw(): void {
this.textView.displayText();
}
}
And usage:
const shapes: Shape[] = [
new TextShape ("Hello, world!"),
new TextShape ("Adapter pattern example")
];
shapes.forEach(shape => {
shape.createManipulator();
const bounds = shape.BoundingBox();
console.log(`Bounds: x=${bounds.x}, y=${bounds.y}, width=${bounds.width}, height=${bounds.height}`);
});
This diagram illustrates the object adapter case (2nd form). It shows how BoundingBox
requests, declared in class Shape
, are converted to GetExtent
requests defined in TextView
. Since TextShape
adapts TextView
to the Shape interface, the drawing editor can reuse the otherwise incompatible TextView
class.
Often the adapter is responsible for functionality the adapted class doesn’t provide. The diagram shows how an adapter can fulfill such responsibilities. The user should be able to “drag
” every Shape
object to a new location interactively, but TextView
isn’t designed to do that. TextShape
can add this missing functionality by implementing Shape
’s CreateManipulator
operation, which returns an instance of the appropriate Manipulator
subclass.
abstract class Manipulator {
abstract drag(x: number, y: number): void;
}
class TextManipulator extends Manipulator {
constructor(private shape: TextShapeAdapter) {
super();
}
drag(x: number, y: number): void {
console.log(`Dragging text shape to (${x}, ${y})`);
// Here you can add logic to update the shape's position.
}
}
class TextShapeAdapter extends Shape {
private textView: TextView;
constructor(text: string) {
super();
this.textView = new TextView(text);
}
BoundingBox(): { x: number, y: number, width: number, height: number } {
return this.textView.GetExtent();
}
createManipulator(): Manipulator {
return new TextManipulator(this);
}
draw(): void {
this.textView.displayText();
}
}
Manipulator
is an abstract class for objects that know how to animate a Shape
in response to user input, like dragging the shape to a new location. There are subclasses of Manipulator
for different shapes
; TextManipulator
, for example, is the corresponding subclass for TextShape
. By returning a TextManipulator
instance, TextShape
adds the functionality that TextView
lacks but Shape requires.
In the next part (Part 2) we will talk about applicability and structure of Adapter design pattern.