TypeScript の 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.mp4startPlayback 関数は MediaPlayer 型だけに依存しており、具体的な実装クラスとは完全に切り離されています。
Adapter パターンの登場人物
パターンを構成する要素を整理しておきましょう。
クライアントが利用するインターフェースです。上の例では MediaPlayer がこれに該当します。クライアントはこのインターフェースだけを知っていれば十分です。
既存のクラスやライブラリで、Target とは異なるインターフェースを持っています。LegacyAudioPlayer や LegacyVideoPlayer のように、変更できない、あるいは変更すべきでないコードを指します。
Target インターフェースを実装しつつ、内部で Adaptee のメソッドを呼び出す仲介役です。変換ロジックはすべてここに閉じ込められます。
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 つの実装方式が存在します。
Adaptee のインスタンスをフィールドとして保持し、委譲で処理を転送する。TypeScript ではこちらが主流であり、柔軟性が高い。
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 パターンの適用が最も適切な場面はどれですか?
- 自分で設計したクラスのメソッド名を変更したい
- 変更できない外部ライブラリを自分のインターフェースに合わせたい
- 複雑なサブシステムに簡易的な窓口を提供したい
- 既存クラスにログ出力機能を追加したい
Adapter パターンは地味ですが、実務では非常に出番の多いパターンです。特に TypeScript の型システムと組み合わせることで、インターフェースの不一致をコンパイル時に検出でき、実行時エラーを未然に防げます。既存コードに手を入れられない制約がある場面で、まず検討すべき選択肢の一つといえるでしょう。
Adapter パターンは、変更できない既存コードのインターフェースを変換するためのパターンです。自分のコードなら直接修正するほうが適切ですし、簡易窓口は Facade、機能追加は Decorator の役割になります。