TypeScriptで学ぶデザインパターン 〜Adapter編〜
はじめに
この記事は前回に引き続き、Java 言語で学ぶデザインパターンで学んだ内容のアウトプットです。フロントエンド開発における「Adapter パターン」の活用例として、何かのヒントになれば幸いです。
Adapter パターンとは?
Adapter パターンは、異なるインターフェースを持つクラスや関数を統一的なインターフェースで扱う方法を提供するデザインパターンです。本の中ではACアダプターによる電圧の変換を例として、異なる規格 (インターフェース) を持つもの同士を適合させる方法が紹介されています。こちらの記事にある「デザインパターンの例」を確認いただくと、イメージが湧くと思います。
Adapter パターンでは、次のような登場人物が出てきます。
Target (対象): いま必要になっているインターフェース
Client (依頼者): Targetで定められたインターフェースを必要としているプログラム
Adaptee (適合される側): 既に用意されているクラス・関数
Adapter (適合させる側): Adapteeを使ってTargetのインターフェースを満たすクラス・関数
具体例: APIのインターフェース変更に対応
ここではバックエンドAPIの仕様変更を例として、Adapter パターンの活用方法を考えましょう。
シナリオとしては、変更前のAPIを呼び出す getTodos
関数はよくテストされており、多数のコンポーネントから呼び出されている (依存先になっている) とします。フロントエンドの改修には時間を要するため、「バックエンドAPIの変更を先行リリースして、既存のコンポーネントへの影響は局所的に抑えたい」となるかもしれません。そんなときがAdapter パターンの出番です。
変更前のコード
変更前の /toods
APIは、次のようなTODOの配列を返します。
type Todo = {
id: number;
title: string;
status: "not_started" | "in_progress" | "completed";
};
こちらは、変更に対応する必要がある関数 (Adaptee) である getTodos
関数です。引数にTODOの状態を指定することができて、指定された場合はその状態のTODOに絞り込んで結果を返します。これらのテストケースが完備されており、再利用できる関数となっています。
// Adaptee
export const getTodos = (filter?: Todo["status"]) => {
const res = await fetch("http://localhost:3000/todos");
const { todos } = (await res.json()) as { todos: Todo[] };
return filter ? todos.filter((todo) => todo.status === filter) : todos;
};
getTodos関数のテスト
import { describe, it, expect, beforeEach } from "vitest";
import { http, HttpResponse } from "msw";
import { Todo } from "@/features/todo/types";
import { server } from "@/mock/node";
import { getTodos } from ".";
describe("getTodos", () => {
const fakeTodos: Todo[] = [
{ id: 1, title: "Learn TypeScript", status: "in_progress" },
{ id: 2, title: "Build API", status: "completed" },
{ id: 3, title: "Write tests", status: "in_progress" },
{ id: 4, title: "Deploy app", status: "not_started" },
];
beforeEach(() => {
// MSWでAPIレスポンスをモックする
server.use(
http.get<never, Todo[]>("/todos", () => {
return HttpResponse.json({
todos: fakeTodos,
});
})
);
});
const testCases: {
filter?: Todo["status"];
expectedIds: number[];
}[] = [
{
filter: undefined,
expectedIds: [1, 2, 3, 4],
},
{
filter: "in_progress",
expectedIds: [1, 3],
},
{
filter: "completed",
expectedIds: [2],
},
];
// Vitestの `test.each` を使って、TODOの各状態をパラメーターとしてテストする
test.each(testCases)(
"returns todos filtered by status: $filter",
async ({ filter, expectedIds }) => {
const todos = await getTodos(filter);
expect(todos).toHaveLength(expectedIds.length);
expect(todos.map((todo) => todo.id)).toEqual(expectedIds);
}
);
});
getTodos
関数は多数のコンポーネント (Client) から利用されています。
import { getTodos } from "@/features/todo/api";
// Client
export const TodoList = () => {
const todos = await getTodos();
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span className={todo.status === "completed" ? "line-through" : ""}>
{todo.title}
</span>
</li>
))}
</ul>
);
};
変更後のコード
続いて、仕様変更後のコードを見ていきましょう。todos
APIのレスポンスが返すTODOには次の変更が入りました。
- 完了状態を表現する文字列は
done
とする - 完了の場合は、完了日
completedAt
を持つ
type DuplicatedTodo = {
id: number;
title: string;
status: "not_started" | "in_progress" | "completed";
};
type Todo = UncompletedTodo | CompletedTodo;
type BaseTodo = {
id: number;
title: string;
};
type UncompletedTodo = BaseTodo & {
status: "not_started" | "in_progress";
};
type CompletedTodo = BaseTodo & {
status: "done";
completedAt: string;
};
Clientである todo-list.tsx
は、変更前の getTodos
関数のインターフェースに依存しています。視点を変えると、インターフェースに変更がなければClientに変更は入らないため、Adapterを間に挟むことで変更の影響を getTodos
関数を定義したファイル内に留めます。
// Step 1: `getTodos` 関数を変更後のAPIレスポンスに合わせる
export const getTodosAdaptee = async (filter?: DuplicatedTodo["status"]) => {
const res = await fetch("http://localhost:3000/todos");
const { todos } = (await res.json()) as { todos: Todo[] };
return filter ? todos.filter((todo) => todo.status === filter) : todos;
};
// Step 2: Target (変更前のインターフェース) を定義する
type GetTodos = (
filter?: DuplicatedTodo["status"]
) => Promise<DuplicatedTodo[]>;
// Step 3: Adapterのファクトリー関数を実装する
const createGetTodosAdapter = (): GetTodos => {
// クロージャの内部にAdapteeインスタンスを持つ
const getTodos = getTodosAdaptee;
return async (filter?: DuplicatedTodo["status"]) => {
const todos = await getTodos(filter);
return todos.map((todo) => {
// ここでTODOオブジェクトの構造をゴニョゴニョしている
if (todo.status === "done") {
const { status: _status, completedAt: _completedAt, ...rest } = todo;
const adaptedTodo: DuplicatedTodo = {
status: "completed",
...rest,
};
return adaptedTodo;
}
return todo;
});
};
};
// Step 4: `getTodosAdaptee` に代わって、Adapterを定義して外部に公開する
export const getTodos: GetTodos = createGetTodosAdapter();
ポイントとして、「createGetTodosAdapter
内部に持つAdapteeを用いてTargetのインターフェースを実装する関数」を返しています。これにより、外から見た振る舞いを維持したまま、バックエンドAPIの仕様変更に対応できました。また、getTodos
関数のテストについても、リネームは必要ですがそのまま使えています。
おわりに
Adapter パターンを使うことで、異なるインターフェースを持つAPIやライブラリを統一的に扱えるようになります。この記事で紹介したサンプルでは、フロントエンドの変更を局所的に留め、よくテストされたコードを再利用できるといった利点がありました。その他にも、様々な形で応用可能なパターンだと思うので、色々試してみると面白そうです。
Discussion