Kgtkr's Blog

TypeScriptで高カインド型(Higher kinded types)

2018/09/18
typescripttypelevelprogramming

はじめに

TypeScript(TS)には高カインド型(HKT)がありません。一応提案がありますが…
しかし様々な機能を組み合わせればHKTを実現することが出来るのでそれの方法と解説を行います。
この記事ではHKTとは何かなどといった解説は行いません。表記は*->*のような表記を使います。
またfp-tsというライブラリを参考にしています。

使う機能

interfaceのマージ(Declaration Merging)

TypeScriptでは同名のinterfaceを複数定義出来ます。
そしてこれらは自動的にマージされます。
例えば次の2つの宣言は同等です。

export interface Hoge {
    x: number;
}

export interface Hoge {
    y: number;
}
export interface Hoge {
    x: number;
    y: number;
}

そしてこの機能は別ファイルや別モジュールで既に定義されているinterfaceを拡張する事も出来ます。
例えばmoment.jsMomentmyFuncという関数を追加してみましょう。

declare module "moment" {
    interface Moment {
        myFunc(): void
    }
}

こうすることで次のコードがコンパイル通るようになります。

import * as moment from "moment";

moment().myFunc();

Index Type

型レベルのインデックスアクセスです。

interface Hoge {
    x: number
}

type X = Hoge["x"];

こうすることでXnumberになります。これだけです。

HKTを実現する

方針

TypeScriptでは型コンストラクタを型パラメーターに渡したりすることが出来ません。
つまり型コンストラクタのままでは不便なのです。
そこで型コンストラクタの実体を一意のIDをつけて別の場所に置いておき、型パラメーターにはそのID(これは型コンストラクタではなく型なので簡単にやり取り出来る)を渡すという事を行います。
IDは文字列リテラル型かunique symbolです。衝突を考えるとunique symbolがおすすめですが、今回は文字列リテラル型を使用します。

実装

まず次のような空のinterfaceを定義します。

hkt.ts

export interface HKT<T> {

}

次に実際にHKTとして使う型コンストラクタを追加していきます。この型の追加は別ファイルから行うことも別パッケージから行う事も出来ます。
また追加は複数に分けて行えます。
同階層の別ファイルから行う場合は次のようになります。

type.ts

export class Type1<T> {
  constructor(public x: T) { }
}

export class Type2<T>{
  constructor(public y: T) { }
}

declare module "./hkt" {
  interface HKT<T> {
    Type1: Type1<T>,
    Type2: Type2<T>
  }
}

この時interfaceのプロパティ名が型コンストラクタのIDとなります(ここでは"Type1""Type2")

次に型コンストラクタを受け取る側の定義です。

functor.ts

import { HKT } from "./hkt";

export interface Functor<T, F extends keyof HKT<any>> {
  map<P>(f: (x: T) => P): HKT<P>[F]
}

ここでいくつかポイントがあります。
まずF extends keyof HKT<any>です。これは受け取る型コンストラクタIDをHKT<T>で定義されているもの、つまり*->*のみに制限しています。
例えば次のような空のinterfaceを定義して、F extends keyof HKT2<any,any>とすれば*->*->*も使えます。

export interface HKT2<T,P> {

}

同じようにしていけば型パラメーターに制約をつけたり、(*->*)->*といった複雑なものにも対応出来ます。
ただし全てのkindに命名しないといけないのでそこは少し面倒です。
ちなみにkeyof HKT<any>HKT<T>に渡しているanyですが、これはどんな型でもkeyofの結果は変わらないので問題ありません。
次にHKT<P>[F]です。
これはまずHKT全体にPを渡して型のマップを作り、[F]で指定のIDの型を取り出す事でF<P>のようなHKTを実現しています。
これがもっとも重要な部分です。

では先ほどのtype.tsを編集してFunctorを実装してみましょう。

type.ts

import { Functor } from "./functor";

export class Type1<T> implements Functor<T, "Type1">{
  constructor(public x: T) { }

  map<P>(f: (x: T) => P): Type1<P> {
    return new Type1(f(this.x));
  }
}

export class Type2<T> implements Functor<T, "Type2">{
  constructor(public y: T) { }

  map<P>(f: (x: T) => P): Type2<P> {
    return new Type2(f(this.y));
  }
}

これはそのままですね。特徴はimplements Functor<T, "Type1">で型コンストラクタのIDを渡しているくらいです。
他は特に問題なくそのまま読めると思います。

注意点など

  • ライブラリ化する時はjsとd.tsで配布するのではなくtsのまま配布して下さい
    • d.tsのkeyof HKT<any>neverになることがありバグります
    • 条件などは不明
    • バグか仕様かの確認のissue出したのでまた追記します
  • また他パッケージのHKT interfaceを拡張する場合は内部のフォルダ構造を確認する必要があり少し複雑です
    • 例えばnode_modules/package-name/src/hkt.tsに定義がある場合declare module "package-name/src/hkt"となります
    • node_modules/package-name/src/index.tsなどでexport * from "./hkt"といったことがされていてもこのように指定する必要があります