メインコンテンツまでスキップ

Adapter — 構造的部分型が消す境界

構造的部分型が引き取る部分と、変換関数やクラスが残る部分を切り分けます。

このパターンが解こうとした問題

GoF は Adapter を、互換性のないインターフェースを持つクラス同士を協働させるためのパターンとして収録しています1。呼び出し側が期待する形と、手元にあるライブラリの形が違うとき、あいだに変換役を挟みます。

このパターンには 2 つの動機が混ざっています。

  1. 形が違う — メソッド名や引数の並びが期待と合わない
  2. 名前が違う — 中身は合っているのに、その型を実装していると宣言していない

TypeScript で減るのは 2 つ目です。1 つ目は言語が何をしようと残ります。

クラスで素直に書く

天気データの取得を題材にします。アプリ側は次の形を期待しているとします。

type Weather = {celsius: number; humidity: number};

interface WeatherSource {
fetchAt(city: string): Promise<Weather>;
}

// 外部ライブラリ。メソッド名も、引数の種類も、単位も、戻り値の形も違う
class LegacyWeatherApi {
async get(_cityCode: string): Promise<{tempF: number; humidityPercent: number}> {
return {tempF: 68, humidityPercent: 40}; // 通信部分は省略
}
}

const CITY_CODES: Record<string, string> = {sendai: '040010'};

// Adapter。期待される形に合わせて包む
class LegacyWeatherAdapter implements WeatherSource {
constructor(private readonly api: LegacyWeatherApi) {}

async fetchAt(city: string): Promise<Weather> {
const raw = await this.api.get(CITY_CODES[city]); // 都市名を都市コードへ
return {
celsius: Math.round(((raw.tempF - 32) * 5) / 9), // 華氏を摂氏へ
humidity: raw.humidityPercent,
};
}
}

この形は TypeScript でもそのまま書けますし、間違ってもいません。次の節では、この構えのうちどこまでが言語側に吸収されるかを見ます。

言語機能で置き換える

TypeScript の型の互換性は名前ではなく形で決まります。

Type compatibility in TypeScript is based on structural subtyping. Structural typing is a way of relating types based solely on their members.2

ハンドブックは名前で判定する言語との違いも明記しています。

In nominally-typed languages like C# or Java, the equivalent code would be an error because the Dog class does not explicitly describe itself as being an implementer of the Pet interface.2

つまり 形が合っていれば、implements を書いていなくても代入できます。名前を合わせるためだけの包み紙には、出番がありません。

ただし同じページに例外も書かれています。

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

形がまったく同じでも、privateprotected のメンバーを持つクラスどうしは、同じクラス由来でないかぎり代入できません。 # 始まりのプライベートフィールドも同じです。この場合は implements を書き足しても通らないので、包み直す変換が残ります。

type Weather = {celsius: number; humidity: number};

interface WeatherSource {
fetchAt(city: string): Promise<Weather>;
}

// implements を書いていないが、形が合っているのでそのまま渡せる
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));

形が違う場合 (動機 1) は、言語が何をしようと変換が残ります。ここでは変換を関数として書いた場合を見ます。

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'};

// 変換関数を挟むだけで、Adapter クラスと同じ役目を果たす
function adaptLegacyApi(api: LegacyApi): WeatherSource {
return {
fetchAt: async (city) => toWeather(await api.get(CITY_CODES[city])),
};
}

toWeather は入力から出力を作るだけの関数なので、単体で読めますし単体でテストできます。クラスの版では、この変換ロジックがメソッドの中に埋まっていました。

本連載の判断

判定: 条件付き。 何を合わせたいかで答えが変わります。

  • 名前だけの不一致 (どちらも素のオブジェクトか、private を持たないクラス) — 対処は要りません。構造的部分型がそのまま解決します。既存の型に合わせるために implements を書き足す作業も生じません。
  • 形は同じだが private / protected を持つクラスどうし — 代入できないので包み直す変換が要ります。上で見たとおり implements を書き足しても通りません。この枝だけは、形が合っていても Adapter が残ります。
  • 形の不一致で、変換に状態が要らない — 変換関数を書きます。adaptLegacyApi のようにオブジェクトを返す関数でまとめると、呼び出し側からは 1 つの入り口に見えます。
  • 形の不一致で、変換に状態が要る — クラスを選びます。接続の保持、認証トークンの更新、応答のキャッシュなど、呼び出しをまたいで持ち回るものがある場合です。

implements を書くこと自体は、無意味ではありません。実装側が意図した契約から外れたときに、実装側のファイルでエラーを出すためのものです。これは型を合わせるためではなく、間違いを早く見つけるための注釈にあたります。

この判定が覆る条件

構造的部分型は「形が合っていれば通る」ので、意味が違うのに形が同じ型を取り違えても気付けませんtype Celsius = numbertype Fahrenheit = number は名前こそ違いますが、型エイリアスなので互いに代入できます。{id: string} を持つ利用者 ID と注文 ID も同じです。単位や識別子を扱う型では、この取り違えが実害を生みます。

この場合は形を区別できるようにする対処 (判別用のプロパティを持たせる、いわゆるブランド型にする) が要り、そこでは「形が合えば通る」という前提そのものを意図的に壊すことになります。

もう 1 つ、toWeather のような変換関数は変換が一方向のときに素直です。読み書きの両方を仲介する必要がある場合は、対になる変換を 1 か所にまとめたくなり、オブジェクトかクラスを選ぶ判断に戻ります。

既存記事との関係

  • クラスとインターフェース は合成を優先する考え方を扱っています。Adapter は合成の具体例の 1 つにあたります。
  • クラスと継承 に抽象クラスとインターフェースの使い分けの比較表があります。本章の implements の扱いは、そこで整理されている内容を前提にしています。

なお、クリーンアーキテクチャの文脈で出てくる「インターフェースアダプター」は層の名前であって、本章の Adapter パターンとは指すものが異なります。

Footnotes

  1. Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides『Design Patterns: Elements of Reusable Object-Oriented Software』(Addison-Wesley, 1994)。Adapter は構造に関するパターンとして収録されています。意図の要約は本ガイドによるもので、原典とは照合していません。

  2. 出典: Type Compatibility(TypeScript Handbook)。 2 3