TypeScript の Adapter パターン - 互換性のないインターフェースをつなぐ

既存のクラスやモジュールを使いたいのに、インターフェースが合わなくて使えない。開発の現場では、こうした場面に頻繁に遭遇します。Adapter パターンは、互換性のないインターフェース同士を仲介し、既存コードに手を加えずに連携を実現する構造パターンです。

変換プラグとしての Adapter

海外旅行でコンセントの形状が異なるとき、変換プラグを使います。Adapter パターンの役割はまさにこの変換プラグと同じで、あるインターフェースを別のインターフェースに変換するラッパーとして機能します。

Adapter なし

呼び出し側が既存クラスのインターフェースに直接依存し、変更のたびにコード全体を修正する必要がある

Adapter あり

間に Adapter を挟むことで、呼び出し側は統一されたインターフェースだけを知っていればよく、既存クラスの変更が波及しない

重要なのは、既存のコード(Adaptee)には一切手を加えないという点です。変更が許されないサードパーティライブラリや、レガシーシステムとの連携で特に威力を発揮します。

基本構造を TypeScript で実装する

まず、呼び出し側が期待するインターフェースと、それに合わない既存クラスを定義してみましょう。

// クライアントが期待するインターフェース(Target)
interface MediaPlayer {
  play(filename: string): string;
}

// 既存の外部ライブラリ(Adaptee)
class LegacyAudioPlayer {
  playMp3(file: string): string {
    return `Playing MP3: ${file}`;
  }
}

class LegacyVideoPlayer {
  playMp4(file: string): string {
    return `Playing MP4: ${file}`;
  }
}

MediaPlayer インターフェースは play() メソッドを要求していますが、既存プレイヤーは playMp3()playMp4() という別名のメソッドしか持っていません。ここで Adapter を作成し、インターフェースの溝を埋めます。

// Audio 用 Adapter
class AudioPlayerAdapter implements MediaPlayer {
  private adaptee: LegacyAudioPlayer;

  constructor(player: LegacyAudioPlayer) {
    this.adaptee = player;
  }

  play(filename: string): string {
    return this.adaptee.playMp3(filename);
  }
}

// Video 用 Adapter
class VideoPlayerAdapter implements MediaPlayer {
  private adaptee: LegacyVideoPlayer;

  constructor(player: LegacyVideoPlayer) {
    this.adaptee = player;
  }

  play(filename: string): string {
    return this.adaptee.playMp4(filename);
  }
}

各 Adapter は MediaPlayer を実装しつつ、内部では Adaptee のメソッドを呼び出しています。クライアント側は play() を呼ぶだけで、背後にどのプレイヤーがいるかを意識する必要がありません。

function startPlayback(player: MediaPlayer, file: string): void {
  console.log(player.play(file));
}

const audioAdapter = new AudioPlayerAdapter(new LegacyAudioPlayer());
const videoAdapter = new VideoPlayerAdapter(new LegacyVideoPlayer());

startPlayback(audioAdapter, "song.mp3");
// Playing MP3: song.mp3

startPlayback(videoAdapter, "movie.mp4");
// Playing MP4: movie.mp4

startPlayback 関数は MediaPlayer 型だけに依存しており、具体的な実装クラスとは完全に切り離されています。

Adapter パターンの登場人物

パターンを構成する要素を整理しておきましょう。

Target(ターゲット)

クライアントが利用するインターフェースです。上の例では MediaPlayer がこれに該当します。クライアントはこのインターフェースだけを知っていれば十分です。

Adaptee(アダプティー)

既存のクラスやライブラリで、Target とは異なるインターフェースを持っています。LegacyAudioPlayerLegacyVideoPlayer のように、変更できない、あるいは変更すべきでないコードを指します。

Adapter(アダプター)

Target インターフェースを実装しつつ、内部で Adaptee のメソッドを呼び出す仲介役です。変換ロジックはすべてここに閉じ込められます。

Client(クライアント)

Target インターフェースを通じて機能を利用する側のコードです。Adaptee の存在を知る必要がなく、Adapter の差し替えだけで振る舞いを変更できます。

実践例:外部 API レスポンスの正規化

より実践的な場面を見てみましょう。複数の天気 API を統一インターフェースで扱うケースです。API ごとにレスポンス形式が異なるのは日常的な問題であり、Adapter パターンが自然に適用できます。

// 統一インターフェース
interface WeatherData {
  city: string;
  temperatureCelsius: number;
  description: string;
}

interface WeatherService {
  getWeather(city: string): WeatherData;
}

// 外部 API A のレスポンス(華氏、英語キー)
interface ApiAResponse {
  location: string;
  temp_f: number;
  condition: string;
}

// 外部 API B のレスポンス(摂氏、ネスト構造)
interface ApiBResponse {
  area: { name: string; country: string };
  main: { temp_c: number; humidity: number };
  weather: { text: string };
}

2 つの API はレスポンス構造がまったく異なります。キー名も違えば、温度の単位も違い、ネストの深さも一致しません。それぞれに対応する Adapter を書きます。

class ApiAAdapter implements WeatherService {
  private api: { fetch(city: string): ApiAResponse };

  constructor(api: { fetch(city: string): ApiAResponse }) {
    this.api = api;
  }

  getWeather(city: string): WeatherData {
    const res = this.api.fetch(city);
    return {
      city: res.location,
      temperatureCelsius: Math.round((res.temp_f - 32) * 5 / 9),
      description: res.condition,
    };
  }
}

class ApiBAdapter implements WeatherService {
  private api: { fetch(city: string): ApiBResponse };

  constructor(api: { fetch(city: string): ApiBResponse }) {
    this.api = api;
  }

  getWeather(city: string): WeatherData {
    const res = this.api.fetch(city);
    return {
      city: res.area.name,
      temperatureCelsius: res.main.temp_c,
      description: res.weather.text,
    };
  }
}

華氏から摂氏への変換やネスト構造の平坦化といったロジックが Adapter 内に閉じ込められています。クライアントは WeatherService インターフェースだけを扱えばよく、API の追加や切り替えがあっても影響を受けません。

function displayWeather(service: WeatherService, city: string): void {
  const data = service.getWeather(city);
  console.log(`${data.city}: ${data.temperatureCelsius}°C - ${data.description}`);
}

この関数はどの API が背後にあるかを一切知らず、テストでもモック実装を簡単に差し込めます。

クラス Adapter とオブジェクト Adapter

Adapter パターンには 2 つの実装方式が存在します。

オブジェクト Adapter(コンポジション)

Adaptee のインスタンスをフィールドとして保持し、委譲で処理を転送する。TypeScript ではこちらが主流であり、柔軟性が高い。

クラス Adapter(継承)

Adaptee を継承して Target インターフェースを実装する。TypeScript は多重継承をサポートしないため、適用場面が限られる。

ここまでの例はすべてオブジェクト Adapter です。コンポジションを用いるため、実行時に Adaptee を差し替えることもできますし、複数の Adaptee を 1 つの Adapter にまとめることも可能です。一方、クラス Adapter は継承を使うため柔軟性に欠けますが、Adaptee のメソッドを直接オーバーライドできるという利点があります。

// クラス Adapter の例(継承ベース)
class AudioClassAdapter extends LegacyAudioPlayer implements MediaPlayer {
  play(filename: string): string {
    return this.playMp3(filename);
  }
}

const player = new AudioClassAdapter();
console.log(player.play("song.mp3"));
// Playing MP3: song.mp3

シンプルではありますが、LegacyAudioPlayer のパブリック API がすべて露出してしまう点に注意が必要です。カプセル化の観点からは、オブジェクト Adapter のほうが安全といえるでしょう。

使いどころの判断

Adapter パターンが有効な場面と、避けるべき場面を整理します。

有効な場面

サードパーティライブラリのインターフェースが自分のコードと合わないとき。レガシーコードを新しいシステムに統合するとき。複数の外部サービスを統一的に扱いたいとき。テスト用のモックを差し込みやすくしたいとき。

避けるべき場面

自分で管理しているコードのインターフェースを変更できる場合は、Adapter を挟むよりインターフェース自体を修正するほうが素直です。また、変換ロジックが複雑すぎる場合は、Adapter の責務を超えている可能性があります。

関連パターンとの違い

Adapter と混同されやすいパターンがいくつかあります。

Adapter は既存インターフェースを別のインターフェースに変換しますが、Facade はまったく異なる目的を持っています。

Facade は複雑なサブシステムを単純化した窓口を提供するパターンで、インターフェースの変換ではなく簡略化が目的。

Decorator パターンもラッパーという点では似ていますが、Decorator は同じインターフェースを保ったまま機能を追加するのに対し、Adapter はインターフェース自体を変換します。Bridge パターンは抽象と実装を分離する設計時のパターンであり、既存コードへの後付け対応が主な Adapter とは適用タイミングが異なります。

次のうち、Adapter パターンの適用が最も適切な場面はどれですか?

  • 自分で設計したクラスのメソッド名を変更したい
  • 変更できない外部ライブラリを自分のインターフェースに合わせたい
  • 複雑なサブシステムに簡易的な窓口を提供したい
  • 既存クラスにログ出力機能を追加したい
__RESULT__

Adapter パターンは、変更できない既存コードのインターフェースを変換するためのパターンです。自分のコードなら直接修正するほうが適切ですし、簡易窓口は Facade、機能追加は Decorator の役割になります。

Adapter パターンは地味ですが、実務では非常に出番の多いパターンです。特に TypeScript の型システムと組み合わせることで、インターフェースの不一致をコンパイル時に検出でき、実行時エラーを未然に防げます。既存コードに手を入れられない制約がある場面で、まず検討すべき選択肢の一つといえるでしょう。