Adapter Design Pattern in TypeScript (Part 3): Consequences

Hooman Momtaheni
5 min readMay 25, 2024

--

In last 2 parts we looked at Intent, Motivation, Applicability & Structure of Adapter design pattern. Now let’s learn more about its consequences.

Consequences

Class and object adapters have different trade-offs.

A class adapter:

· Adapts Adaptee to Target by committing to a concrete Adaptee class. As a consequence, a class adapter won’t work when we want to adapt a class and all its subclasses.

· Let’s Adapter override some of Adaptee’s behavior, since Adapter is a subclass of Adaptee. (In our main example mixin can override TextView methods).

· introduces only one object, and no additional pointer indirection (adaptee object) is needed to get to the adaptee. Because we don’t have multiple inheritance in TypeScript and we use mixin, this is not the case in TypeScript unless we create adapter using target interface instead of target class. For example:

// Adaptee Class
class Adaptee {
specificRequest(): string {
return "Adaptee's specific request";
}
}

// Target Interface
interface Target {
request(): string;
}

// Class Adapter
class ClassAdapter extends Adaptee implements Target {
request(): string {
return this.specificRequest();
}
}

// Usage
const adapter = new ClassAdapter();
console.log(adapter.request()); // Output: "Adaptee's specific request"

In above example ClassAdapter directly extends Adaptee, so calling adapter.request() directly invokes specificRequest() without any extra layers of referencing.

An object adapter:

· lets a single Adapter work with many Adaptees — that is, the Adaptee itself and all of its subclasses (if any). The Adapter can also add functionality to all Adaptees at once.

· makes it harder to override Adaptee behavior. It will require subclassing Adaptee and making Adapter refer to the subclass rather than the Adaptee itself.

Let’s make it clear by example:

Let’s start with a simple Adaptee class.

class Adaptee {
specificRequest(): string {
return "Adaptee's specific request";
}
}

Here’s the Target interface that the client expects.

class ObjectAdapter implements Target {
private adaptee: Adaptee;

constructor(adaptee: Adaptee) {
this.adaptee = adaptee;
}

request(): string {
return this.adaptee.specificRequest();
}
}

// Usage
const adaptee = new Adaptee();
const adapter = new ObjectAdapter(adaptee);
console.log(adapter.request()); // Output: "Adaptee's specific request"

Now, let’s say we want to override the behavior of the Adaptee class. So that in the end client that use request() method, get different message. With the object adapter pattern, we need to subclass Adaptee and make the adapter use this subclass.

Create a subclass that overrides the behavior.

class CustomAdaptee extends Adaptee {
specificRequest(): string {
return "CustomAdaptee's specific request";
}
}

And now client instead if using Adaptee, uses CustomeAdaptee.

// Usage
const customAdaptee = new CustomAdaptee();
const adapter = new ObjectAdapter(customAdaptee);
console.log(adapter.request()); // Output: "CustomAdaptee's specific request"

Beside subclassing the adapter needs to be configured to use the new subclass. If the adapter was used in many places, every instance would need to be updated to use the new subclass.

But if we used class adapter, for overriding adaptee behavior we simple override it in mixin function.

Here are other issues to consider when using the Adapter pattern:

  1. How much adapting does Adapter do? Adapters vary in the amount of work they do to adapt Adaptee to the Target interface. There is a spectrum of possible work, from simple interface conversion — for example, changing the names of operations — to supporting an entirely different set of operations. The amount of work Adapter does depend on how similar the Target interface is to Adaptee’s.
  2. Pluggable adapters. A class is more reusable when you minimize the assumptions other classes must make to use it. By building interface adaptation into a class, you ensure that other classes don’t have to use the same interface. Put another way, interface adaptation lets us incorporate our class into existing systems that might expect different interfaces to the class. We use the term pluggable adapter to describe classes with built-in interface adaptation.
    Consider a TreeDisplay widget that can display tree structures graphically. If this were a special-purpose widget for use in just one application, then we might require the objects that it displays to have a specific interface; that is, all must descend from a Tree abstract class. But if we wanted to make TreeDisplay more reusable (say we wanted to make it part of a toolkit of useful widgets), then that requirement would be unreasonable. Applications will define their own classes for tree structures. They shouldn’t be forced to use our Tree abstract class. Different tree structures will have different interfaces.
    In a directory hierarchy, for example, children might be accessed with a GetSubdirectories operation, whereas in an inheritance hierarchy, the corresponding operation might be called GetSubclasses. A reusable TreeDisplay widget must be able to display both kinds of hierarchies even if they use different interfaces. In other words, the TreeDisplay should have interface adaptation built into it.
    We’ll look at different ways to build interface adaptation into classes in the Implementation section.
  3. Using two-way adapters to provide transparency. A potential problem with adapters is that they aren’t transparent to all clients. An adapted object no longer conforms to the Adaptee interface, so it can’t be used as is wherever an Adaptee object can. Two-way adapters can provide such transparency. Specifically, they’re useful when two different clients need to view an object differently.
    Consider the two-way adapter that integrates Unidraw, a graphical editor framework, and QOCA, a constraint-solving toolkit. Both systems have classes that represent variables explicitly: Unidraw has StateVariable, and QOCA has ConstraintVariable. To make Unidraw work with QOCA, ConstraintVariable must be adapted to StateVariable; to let QOCA propagate solutions to Unidraw, StateVariable must be adapted to ConstraintVariable.
// StateVariable class in Unidraw
class StateVariable {
value: number;

constructor(value: number) {
this.value = value;
}

getValue(): number {
return this.value;
}

setValue(value: number): void {
this.value = value;
}

// Other StateVariable specific methods
}

// ConstraintVariable class in QOCA
class ConstraintVariable {
constraintValue: number;

constructor(constraintValue: number) {
this.constraintValue = constraintValue;
}

getConstraintValue(): number {
return this.constraintValue;
}

setConstraintValue(constraintValue: number): void {
this.constraintValue = constraintValue;
}

// Other ConstraintVariable specific methods
}
Credit: Design Patterns: Elements of Reusable Object-Oriented Software

The solution involves a two-way class adapter ConstraintStateVariable, a subclass of both StateVariable and ConstraintVariable, that adapts the two interfaces to each other. Multiple inheritance is a viable solution in this case because the interfaces of the adapted classes are substantially different. The two-way class adapter conforms to both of the adapted classes and can work in either system.

// Two-way adapter class
class ConstraintStateVariable extends StateVariable {
private constraintVariable: ConstraintVariable;

constructor(value: number) {
super(value);
this.constraintVariable = new ConstraintVariable(value);
}

// StateVariable methods
getValue(): number {
return this.constraintVariable.getConstraintValue();
}

setValue(value: number): void {
super.setValue(value);
this.constraintVariable.setConstraintValue(value);
}

// ConstraintVariable methods
getConstraintValue(): number {
return this.getValue();
}

setConstraintValue(constraintValue: number): void {
this.setValue(constraintValue);
}

// Additional methods can be adapted similarly
}

// Using ConstraintStateVariable in Unidraw context
const unidrawStateVariable = new ConstraintStateVariable(10);
console.log("Unidraw - Initial State Value:", unidrawStateVariable.getValue()); // 10
unidrawStateVariable.setValue(20);
console.log("Unidraw - Updated State Value:", unidrawStateVariable.getValue()); // 20

// Using ConstraintStateVariable in QOCA context
const qocaConstraintVariable = unidrawStateVariable as unknown as ConstraintVariable;
console.log("QOCA - Initial Constraint Value:", qocaConstraintVariable.getConstraintValue()); // 20
qocaConstraintVariable.setConstraintValue(30);
console.log("QOCA - Updated Constraint Value:", qocaConstraintVariable.getConstraintValue()); // 30

// Ensure the value is synchronized between contexts
console.log("Unidraw - Synchronized State Value:", unidrawStateVariable.getValue()); // 30

constraintStateVariable Adapts both StateVariable and ConstraintVariable by subclassing StateVariable and containing a ConstraintVariable instance. This class synchronizes the values between the two systems. And Usage demonstrates how ConstraintStateVariable can be used in both the Unidraw and QOCA contexts, ensuring that changes in one system are reflected in the other.

In the Part 4 we will talk about Adapter pattern implementation.

--

--

Hooman Momtaheni

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