TSでType Class Patternを実装してみる
TL;DR
- fp-ts の Introduction series to FP-TS を読んで、関数型を学習してみるシリーズ。
- Intro to fp-ts, part 2: Type Class Patternを読んで実装してみる。
Type Class
Type Class は、Haskell などの純粋関数型言語から来た「型に対する振る舞い(インターフェース)を定義し、それを具体的な型に “インスタンス化” していく」デザインパターンです。
Type Class では「ある型 A がクラス B に属するとは、A に対して B が定める操作群が実装されている」と言います。
? よくわからないですね。
要するに、
普通の Class はデータ と メソッド をひとまとめにしたものに対して、Type Class は「この型は こういう操作ができます」と定義する型と言えます。
(TS では暗黙の引数がないため、型クラスは Dictionary を明示的に引数として渡すことでエミュレートします。)
ポイントは次の 3 つ
- インターフェース に「必要なメソッドだけ」を書く
- そのインターフェースを 実装した値(インスタンス) を関数に渡す
- 関数は「できること」だけ見て処理し、具体的な型やデータ構造を気にしない
このポイントを踏まえて簡単な例を見ていきます。
シンプルな例
たとえば、任意の型 A を文字列に変換する機能を表す Show<A> を次のように定義してみます。
interface Show<A> {
readonly show: (a: A) => string;
}
この interface は次のように言うことができます。
A 型が Show Class (ts では interface) に属するのは、A に対して関数: show(a: A): string が定義されている場合である。
これを元に Show Class を例えば以下のように実装できます。
const showString: Show<string> = {
show: (s) => JSON.stringify(s),
};
const showNumber: Show<number> = {
show: (n) => n.toString(),
};
type User = {
name: string;
age: number;
};
const showUser: Show<User> = {
show: (user) => `User "${user.name}", ${user.age} years old`,
};
合成(composition)を用いた例
発展するとジェネリクスを使用して、任意の型に対して Show Class を定義できます。
これはタプルに対する Show Type Class の実装例です。
// T = Show<any>[]
const getShowTuple = <T extends Array<Show<any>>>(
...shows: T
): Show<{ [K in keyof T]: T[K] extends Show<infer A> ? A : never }> => ({
show: (t) => `[${t.map((a, i) => shows[i].show(a)).join(', ')}]`,
});
const showTuple = getShowTuple(showString, showNumber, showUser);
console.log(showTuple.show(['hello', 42, { name: 'Alice', age: 30 }]));
// => [ "hello", 42, User "Alice", 30 years old ]
この getShowTuple は、TypeScript 上で「複数の要素それぞれに対する Show インスタンス(辞書)を受け取って、タプル全体に対する Show インスタンスを構築する」という Type Class のパターンを表現しています。
- 引数
showsは[showNumber, showString]など Show インスタンスのタプル - 返り値 は “タプル全体を表示できる” Show
- show メソッド内で同じ
shows[i].show()を呼ぶだけ
つまり、Show<A> インターフェースが “型 A をどう文字列化するか” という振る舞いを定義しており(振る舞いを抽象化)、getShowTuple は複数のインスタンスを受け取って新しいインスタンスを合成することで
「Type Class の Dictionary を明示的に渡して、コンテナや構造をまたいだ振る舞いの汎用化」を実現しています。
別の例
Comment 型を UI コンポーネントに変換する例を考えてみます。
型クラスの定義
interface ToComponent<A> {
render: (value: A) => Component; // “A をどう描画するか” だけを約束
}
const commentToComponent: ToComponent<Comment> = {
render: (c) => (
<>
{c.text} by {c.author} at {c.createdAt.toLocaleString()}
</>
),
};
型クラス ToComponent<A> が抽象化しているのは「値 A を UI 要素に変換できる能力」 だけ。
この時点では 配列かツリーか といったコンテナ構造には触れていません。
Comment の配列を component に変換する例
const arrayToComponent = <A>(
TCA: ToComponent<A> // 要素用の実装を依存注入
): ToComponent<A[]> => ({
render: as => (
<ul>
{as.map(a => <li>{TCA.render(a)}</li>)}
</ul>
)
})
Tree の component への変換する例
type Tree<A> = { value: A; children: Tree<A>[] }
const treeToComponent = <A>(
TCA: ToComponent<A>
): ToComponent<Tree<A>> => ({
render: t => (
<div className="node">
{TCA.render(t.value)}
<div className="indent">
{t.children.map(child =>
treeToComponent(TCA).render(child)
)}
</div>
</div>
)
})
どちらも「要素を描画する型クラス (TCA)」を引数でもらい、自分自身 (Array / Tree) を描画できる、より大きな Type Class を返しています。
UI レイヤでの使用
以下のようにすると、UI レイヤは「どの ToComponent を使うか」を決めるだけで、配列やツリーの違いを意識せずに描画できます。
const renderComments =
<F extends URIS>(TCF: ToComponent<Kind<F, Comment>>) =>
(comments: Kind<F, Comment>) =>
TCF.render(comments);
// ① Comment Listの描画
const flat: Comment[] = getFlatComments();
renderComments(arrayToComponent(commentToComponent))(flat);
// ② Treeの描画
const tree: Tree<Comment> = getCommentHierarchy();
renderComments(treeToComponent(commentToComponent))(tree);
renderComments は “コンテナ F に入った Comment を描画できる能力” だけ要求します。
renderComments 内部に一切の変更をせず、List でも Tree でも 引数に渡す関数の差し替えだけで済む設計になりました。
“データ構造の選択” を 呼び出し側の責務として外へ追い出せたということになります。
まとめ
Type Class Pattern は、型に対する振る舞いを定義し、それを具体的な型にインスタンス化することで、関数の汎用性と再利用性を高める手法です。
TS では、型クラスを Dictionary として実装し、関数に渡すことで、異なるデータ構造に対しても同じ関数を使い回すことができます。
次回はIntro to fp-ts, part 3: Option and Eitherを読んでいきます。
Discussion