やりたいこと
TypeScriptにおける型クラス(Type Class) について整理したい。
- 断片的なコードでは理解が難しいので、TypeScript Playgroundで動かして試せるコードを用意する
- リンクを開いて「実行」を押せばすぐ結果が表示される
- 誰でもコードを改変していろいろ試すことができる
- 長くないコードで比較的理解しやすいSemigroup, Monoid, Functorについて扱う
- これらはTypeScriptに限らず、関数型プログラミングでは一般的な概念
- Haskellや圏論等由来の用語はなるべく使わない
- 厳密さや網羅性は犠牲になっている
TypeScriptにおける型クラスとは
任意の型 A
に対して、「こんな操作ができるよ」と定義した「型」。
型クラスの実例1 Semigroup
サンプルコード SemigroupSample.ts (TypeScript Playground)
Semigroupの型クラス
任意の型 A
に対して「結合(concat
)ができるよ」と定義した「型」。
interface Semigroup<A> {
readonly concat: (x: A, y: A) => A
}
Semigroupのインスタンス(string編)
型クラスを「任意の型」で実現したものをインスタンスと呼ぶ。
以下はstringに対して「結合(文字列連結)ができるよ」と定義した「インスタンス」。
const semigroupString: Semigroup<string> = {
concat: (x, y) => x + y,
}
以下のように使うことができる。
semigroupString.concat('hoge', 'fuga') // hogefuga
Semigroupの「結合」は、以下のように結合法則を満たす。
これはインスタンスを作る側が動作を保証する必要がある。
semigroupString.concat('hoge', semigroupString.concat('fuga', 'piyo')) // hogefugapiyo
semigroupString.concat(semigroupString.concat('hoge', 'fuga'), 'piyo') // hogefugapiyo
Semigroupのインスタンス(number編)
以下はnumberに対して「結合(加算)ができるよ」と定義した「インスタンス」。
const semigroupNumberAdd: Semigroup<number> = {
concat: (x, y) => x + y,
}
semigroupNumberAdd.concat(10, 20) // 30
「結合」の処理は、結合法則を満たす処理なら何でもよい。
以下はnumberに対して「結合(乗算)ができるよ」と定義した「インスタンス」。
const semigroupNumberMultiply: Semigroup<number> = {
concat: (x, y) => x * y,
}
semigroupNumberMultiply.concat(10, 20) // 200
Semigroupのインスタンス(独自型編)
例えば以下のようなMenu
型があったとする。
type Menu = {
name: string
price: number // 円
calorie: number // kcal
solt: number // g
}
「結合」の操作が可能な型なら、Semigroupのインスタンスを作ることができる。
以下はMenu
に対して「結合(メニューのまとめ?)ができるよ」と定義した「インスタンス」。
const semigroupMenu: Semigroup<Menu> = {
concat: (x, y) => {
return {
name: x.name + 'と' + y.name,
price: x.price + y.price,
calorie: x.calorie + y.calorie,
solt: x.solt + y.solt,
}
},
}
これは以下のように使うことができる。
const gohan: Menu = {
name: 'ご飯',
price: 120,
calorie: 330,
solt: 0,
}
const misoshiru: Menu = {
name: '味噌汁',
price: 80,
calorie: 25,
solt: 1.5,
}
const karaage: Menu = {
name: '唐揚げ',
price: 250,
calorie: 350,
solt: 3,
}
semigroupMenu.concat(gohan, misoshiru) // { name: 'ご飯と味噌汁', calorie: 355, solt: 1.5}
Semigroupの共通処理
ここまで見たとおりSemigroupであれば、「任意の型」を2つ「結合(concat
)」に渡せば、「任意の型」が返ってくる。
このため「任意の型」が何なのかに関わらず、共通に使える処理を書くことができる。
function printSemi<A>(semi: Semigroup<A>): (x: A, y: A) => void {
return (x, y) => console.log(semi.concat(x, y))
}
printSemi(semigroupString)('hoge', 'fuga') // hogefuga
printSemi(semigroupNumberAdd)(10, 20) // 30
printSemi(semigroupNumberMultiply)(10, 20) // 200
printSemi(semigroupMenu)(semigroupMenu.concat(gohan, misoshiru), karaage)
// { "name": "ご飯と味噌汁と唐揚げ", "price": 450, "calorie": 705, "solt": 4.5 }
型クラスの実例2 Monoid
サンプルコード MonoidSample.ts (TypeScript Playground)
Monoidの型クラス
任意の型 A
に対して「結合(concat
)ができるよ」「基本の値(empty
)があるよ」と定義した「型」。
interface Semigroup<A> {
readonly concat: (x: A, y: A) => A
}
interface Monoid<A> extends Semigroup<A> {
readonly empty: A
}
MonoidはSemigroupに「基本の値」というルールが増えたもの。
このためSemigroupをextendsして定義している。
Monoidのインスタンス(string編)
以下はstringに対して「結合(文字列連結)ができるよ」「基本の値(空文字)があるよ」と定義した「インスタンス」。
const monoidString: Monoid<string> = {
concat: (x, y) => x + y,
empty: '',
}
インスタンスを作るとき、「基本の値」は「どの順番で何度結合しても、全体の結果は変わらない」ような値にする。
これもインスタンスを作る側が動作を保証する必要がある。
monoidString.concat('hoge', monoidString.empty) // hoge
monoidString.concat(monoidString.empty, 'hoge') // hoge
monoidString.concat(
monoidString.empty,
monoidString.concat('hoge', monoidString.empty)
) // hoge
Monoidのインスタンス(number編)
以下はnumberに対して「結合(加算)ができるよ」「基本の値(0
)があるよ」と定義した「インスタンス」。
const monoidNumberAdd: Monoid<number> = {
concat: (x, y) => x + y,
empty: 0,
}
以下はnumberに対して「結合(乗算)ができるよ」「基本の値(1
)があるよ」と定義した「インスタンス」。
const monoidNumberMultiply: Monoid<number> = {
concat: (x, y) => x * y,
empty: 1,
}
このように、型と処理によって「基本の値」は変わってくる。
Monoidのインスタンス(独自型編)
以下はMenu
に対して「結合(メニューのまとめ?)ができるよ」「基本の値(空のメニュー?)があるよ」と定義した「インスタンス」。
const monoidMenu: Monoid<Menu> = {
concat: (x, y) => {
return {
name: x.name.length === 0 ? y.name : x.name + 'と' + y.name,
price: x.price + y.price,
calorie: x.calorie + y.calorie,
solt: x.solt + y.solt,
}
},
empty: {
name: '',
price: 0,
calorie: 0,
solt: 0,
},
}
Monoidの共通処理
Monoidは「基本の値」があるため、「全部をくっつける」などの処理が作りやすくなる。
function concatAll<A>(monoid: Monoid<A>): (array: Array<A>) => A {
return (array) => array.reduce(monoid.concat, monoid.empty)
}
console.log(concatAll(monoidString)(['hoge', 'fuga', 'piyo'])) // hogefugapiyo
console.log(concatAll(monoidNumberAdd)([10, 20, 30])) // 60
console.log(concatAll(monoidNumberMultiply)([10, 20, 30, 40])) // 240000
console.log(concatAll(monoidMenu)([gohan, misoshiru, karaage, salad]))
// { "name": "ご飯と味噌汁と唐揚げとサラダ", "price": 600, "calorie": 765, "solt": 4.9 }
型クラスの実例3 Functor
サンプルコード FunctorSample.ts (TypeScript Playground)
Functorの型クラス
任意の型 F
に対して「何らかの処理(map
)ができるよ」と定義した「型」。
export interface Functor<F> {
readonly map: <A, B>(fa: F<A>, f: (a: A) => B) => F<B>
}
イメージは上の通りだが、上のコードはエラーになる。
TypeScriptでFunctorを実現するには一工夫が必要。
(詳細はサンプルコードを参照)
工夫した結果は以下の通り。
interface Functor<F extends URIS> {
readonly URI: F
readonly map: <A, B>(fa: Kind<F, A>, f: (a: A) => B) => Kind<F, B>
}
意味的には最初の通りなので、URIとかKindとかはひとまず無視していい。
「何らかの処理(map
)」をもう少し噛み砕くと、以下のようなかんじ。
- 型
F<A>
の値fa
を受け取って - 何かしらの関数
(a: A) => B
を適用して - 型
F<B>
の結果を返す
Functorのインスタンス
今回の「任意の型」は、「値があるかも、ないかも?」を表すMyOption
型を定義する。
(一般的なOptionを簡素化したもの)
type None = { _tag: 'none' }
type Some<A> = { _tag: 'some', value: A }
type MyOption<A> = None | Some<A>
以下はMyOption
に対して「何らかの処理(map
)ができるよ」と定義した「インスタンス」。
const functorMyOption: Functor<URI> = {
URI,
map: <A, B>(fa: MyOption<A>, f: (a: A) => B): MyOption<B> => {
return fa._tag === 'some'
? { _tag: 'some', value: f(fa.value) }
: fa
}
}
これも噛み砕くと、以下のようなかんじ。
- 型
MyOption<A>
の値fa
を受け取って - 値があれば(some)
- 何かしらの関数
(a: A) => B
を適用して - 型
MyOption<B>
の結果を返す
- 何かしらの関数
- 値がなければ(none)
- そのまま返す
これを使い、以下のように「値のある場合だけ処理を実行する」コードを書くことができる。
const optA: MyOption<never> = { _tag: 'none' }
const optB: MyOption<string> = { _tag: 'some', value: 'hogehoge' }
console.log(functorMyOption.map(optA, (s: string) => s.length)) // { _tag: 'none' }
console.log(functorMyOption.map(optB, (s: string) => s.length)) // { _tag: 'some', value: 8 }
FunctorからApplicative, Monadができる
Functorの型クラスに
「値を何かしら処理して任意の型 F
として返却できるよ(of
, pure
)」
の定義が加わればApplicativeの型クラスになり、
Applicativeの型クラスに
「任意の型 F
の値と、A
を F<B>
に変換する関数を渡して、F<B>
を返却できるよ(chain
, flatMap
)」
の定義が加わればMonadの型クラスになる。
// Applicative で加わる定義
readonly of: <A>(a: A) => F<A>
// Monad で加わる定義
readonly chain: <A, B>(fa: F<A>, f: (a: A) => F<B>) => F<B>
(上記はイメージ、前述のFunctorの定義と同様にエラーになる)
長くなりそうなので別の機会に。
参考文献
TypeScriptの関数型ライブラリ fp-ts
Scalaライブラリ「cats」の解説 - Type classes
TypeScriptと型クラス
30分でわかるJavaScriptプログラマのためのモナド入門