Adapter — The Boundary Structural Subtyping Removes
This chapter separates the part structural subtyping takes over from the part where a conversion function or class remains.
The problem this pattern set out to solve
GoF catalogs Adapter as a pattern for letting classes with incompatible interfaces work together1. When the shape the caller expects differs from the shape of the library at hand, you slot a converter in between.
Two motivations are mixed together in this pattern.
- The shape differs — method names or the order of arguments do not match what is expected
- The name differs — the contents match, but the type never declares that it implements the other
What TypeScript reduces is the second one. The first remains no matter what the language does.
Writing it straightforwardly with classes
The subject here is fetching weather data. Suppose the application expects this shape.
type Weather = {celsius: number; humidity: number};
interface WeatherSource {
fetchAt(city: string): Promise<Weather>;
}
// External library. The method name, the argument kind, the units, and the return shape all differ
class LegacyWeatherApi {
async get(_cityCode: string): Promise<{tempF: number; humidityPercent: number}> {
return {tempF: 68, humidityPercent: 40}; // Network call omitted
}
}
const CITY_CODES: Record<string, string> = {sendai: '040010'};
// Adapter. Wraps it to match the expected shape
class LegacyWeatherAdapter implements WeatherSource {
constructor(private readonly api: LegacyWeatherApi) {}
async fetchAt(city: string): Promise<Weather> {
const raw = await this.api.get(CITY_CODES[city]); // City name to city code
return {
celsius: Math.round(((raw.tempF - 32) * 5) / 9), // Fahrenheit to Celsius
humidity: raw.humidityPercent,
};
}
}
You can write this shape in TypeScript as is, and there is nothing wrong with it. The next section looks at how much of this arrangement the language absorbs.
Replacing it with language features
Type compatibility in TypeScript is decided by shape, not by name.
Type compatibility in TypeScript is based on structural subtyping. Structural typing is a way of relating types based solely on their members.2
The Handbook also spells out the difference from languages that decide by name.
In nominally-typed languages like C# or Java, the equivalent code would be an error because the
Dogclass does not explicitly describe itself as being an implementer of thePetinterface.2
In other words, if the shape matches, it can be assigned even without an implements. A wrapper that exists only to line the names up has nothing to do.
The same page also records an exception, though.
Private and protected members in a class affect their compatibility. When an instance of a class is checked for compatibility, if the target type contains a private member, then the source type must also contain a private member that originated from the same class. ... This allows a class to be assignment compatible with its super class, but not with classes from a different inheritance hierarchy which otherwise have the same shape.2
Even when the shapes are identical, classes that carry private or protected members cannot be assigned to each other unless the members come from the same class. Private fields that start with # behave the same way. Adding an implements does not get you through here, so a wrapping conversion remains.
type Weather = {celsius: number; humidity: number};
interface WeatherSource {
fetchAt(city: string): Promise<Weather>;
}
// No implements here, but the shape matches, so it can be passed straight through
const inMemorySource = {
async fetchAt(_city: string): Promise<Weather> {
return {celsius: 20, humidity: 40};
},
};
async function report(source: WeatherSource, city: string): Promise<string> {
const {celsius} = await source.fetchAt(city);
return `${city}: ${celsius}℃`;
}
report(inMemorySource, 'sendai').then((text) => console.log(text));
When the shape differs (motivation 1), a conversion remains no matter what the language does. Here is what that conversion looks like written as a function.
type Weather = {celsius: number; humidity: number};
type LegacyWeather = {tempF: number; humidityPercent: number};
interface WeatherSource {
fetchAt(city: string): Promise<Weather>;
}
type LegacyApi = {get(cityCode: string): Promise<LegacyWeather>};
function toWeather(raw: LegacyWeather): Weather {
return {
celsius: Math.round(((raw.tempF - 32) * 5) / 9),
humidity: raw.humidityPercent,
};
}
const CITY_CODES: Record<string, string> = {sendai: '040010'};
// Just slotting in a conversion function does the same job as the Adapter class
function adaptLegacyApi(api: LegacyApi): WeatherSource {
return {
fetchAt: async (city) => toWeather(await api.get(CITY_CODES[city])),
};
}
toWeather only builds an output from an input, so it reads on its own and can be tested on its own. In the class version, that conversion logic was buried inside a method.
How this series judges it
Verdict: conditional. The answer changes with what you are trying to line up.
- A mismatch in name only (both plain objects, or classes with no
private) — nothing to do. Structural subtyping resolves it directly. You do not even have to add animplementsto line up with an existing type. - Classes with the same shape that carry
private/protected— they cannot be assigned, so a wrapping conversion is needed. As shown above, adding animplementsdoes not get you through. This is the one branch where Adapter survives even though the shapes match. - A mismatch in shape where the conversion needs no state — write a conversion function. Wrapping it in a function that returns an object, as
adaptLegacyApidoes, gives the caller a single entry point. - A mismatch in shape where the conversion needs state — pick a class. This is the case when something has to be carried across calls: holding a connection, refreshing an auth token, caching responses.
Writing implements is not meaningless in itself. It exists to raise an error in the implementing file when the implementation drifts from the contract it intended. That is an annotation for catching mistakes early, not for making types line up.
What would overturn this judgment
Structural subtyping lets anything through as long as the shape matches, so it cannot notice when you mix up two types that have the same shape but different meanings. type Celsius = number and type Fahrenheit = number differ in name, but they are type aliases and remain mutually assignable. A user ID and an order ID that both carry {id: string} are the same story. In types that stand for units or identifiers, that mix-up causes real damage.
There the fix is to make the shapes distinguishable (giving them a discriminating property — a branded type), and that means deliberately breaking the very premise that a matching shape gets through.
One more thing: a conversion function like toWeather is at its most natural when the conversion runs one way. When you have to mediate both reads and writes, you start wanting the paired conversions in one place, and the decision returns to choosing an object or a class.
How this relates to existing articles
- Classes and interfaces covers the idea of preferring composition. Adapter is one concrete example of composition.
- Inheritance and interfaces has a comparison table for choosing between abstract classes and interfaces. This chapter's treatment of
implementsbuilds on what is organized there.
Note that the "interface adapter" that comes up in the context of clean architecture is the name of a layer and points at something other than this chapter's Adapter pattern.