Tagged Unionパターンマッチ
Tagged Union型でタグごとの処理をしたい時のイディオムを書いてみます.
Tagged Union
TypeScript ではしばしば以下のように型を表現することがあります.
type Hoge = {
type: 'hoge',
hoge: number,
};
type Fuga = {
type: 'fuga',
fuga: number,
};
type HogeFuga = Hoge | Fuga;
このように共通のプロパティ type
を持つ複数の型を共用型でまとめた型を Tagged Union Type
といいます1.
この型に対して if-else
文で分岐処理を書くと
const item: Hoge = {
type: 'hoge',
hoge: 1,
}
if (item.type === 'hoge') {
// for hoge
} else if (item.type === 'fuga') {
// for fuga
}
のように分岐を連ねていくことになります. しかし, これでは分岐が網羅しているかをチェックできず, バグの温床になります.
Pattern Match Object
そこで, 予め各Unionの型に対する処理をまとめたオブジェクトを作ってそれを呼び出すという発想になります.
const matchers = {
hoge: (item: Hoge) => {
// for hoge
},
fuga: (item: Fuga) => {
// for fuga
},
}
const item: Hoge = {
type: 'hoge',
hoge: 1,
}
matchers[item.type](item)
勿論上のように, matchers
の型推論を任せることも出来ますが, matchers
が HogeFuga
を網羅しているかは保証されません. なので matchers[item.type]
が undefined
になる可能性もあります.
そこで matchers
を表す型を考えます. Discriminated Union
の各型を分解し, プロパティにマップするような型を考えれば良いので, 以下のようになります (2 を参考にしました).
type DiscriminateUnion<T, K extends keyof T, V extends T[K]>
= T extends Record<K, V> ? T : never
type MapDiscriminatedUnion<T extends Record<K, string>, K extends keyof T>
= { [V in T[K]]: DiscriminateUnion<T, K, V> };
type A = MapDiscriminatedUnion<HogeFuga, 'type'>
// type A = {
// hoge: Hoge;
// fuga: Fuga;
// }
この値部分を関数の型にマップすればよいので Mapped Types
を使い,
type Key = string | number | symbol
type Matchers<
U extends Record<K, string>,
K extends Key
> = {
[L in keyof MapDiscriminatedUnion<U, K>]: (v: MapDiscriminatedUnion<U, K>[L]) => void
}
const matchers: Matchers<HogeFuga, 'type'> = {
hoge: (v) => {
console.log(v.hoge);
},
fuga: (v) => {
console.log(v.fuga);
},
};
const item1: Fuga = { type: 'fuga', fuga: 1 };
matchers[item1.type](item1);
出来ました. では, パターンマッチオブジェクトの関数を呼び出す部分を関数化してみましょう.
const match = <
T extends U,
U extends Record<K, string>,
K extends Key,
>(item: T, key: K, matchers: Matchers<U, K>) => {
matchers[item[key]](item)
// ^^^^
// Argument of type 'T' is not assignable to parameter of type 'DiscriminateUnion<U, K, T[K]>'.
// Type 'U' is not assignable to type 'DiscriminateUnion<U, K, T[K]>'.
// Type 'Record<K, string>' is not assignable to type 'DiscriminateUnion<U, K, T[K]>'.
}
型のエラーが出ます. これは, matchers[item[key]]
の引数の型が共用型のある具体的な型であるのに対し, item
が T extends U
つまり共用体全体を表す型であることに起因するエラーです(ここ自信ないです).
本来ならば matchers[item[key]]
の引数の型は U
のある型 T
であるので存在型を用いることで解決するのですが, TypeScriptの型システムには存在型は実装されていません.
Encoding Existential Types
そこで, カプセル化を用いて存在型を回避するような変換を行います. TypeScriptでexistential typeが欲しくなったときはカプセル化で我慢しよう を参考にしました.
// for avoid using existential type
const getMatcher = <
T extends U,
U extends Record<K, string>,
K extends Key,
>(item: T, key: K, matchers: Matchers<U, K>): (item: T) => void =>
(item) => matchers[item[key]];
const match = <
T extends U,
U extends Record<K, string>,
K extends Key,
>(item: T, key: K, matchers: Matchers<U, K>) =>
getMatcher(item, key, matchers)(item);
T
型が直接出てこない形にすることで型エラーを解決することが出来ました.
const matchers: Matchers<HogeFuga, 'type'> = {
hoge: (v) => {
console.log(v.hoge);
},
fuga: (v) => {
console.log(v.fuga);
},
};
const item1: Fuga = { type: 'fuga', fuga: 1 };
const item2: Hoge = { type: 'hoge', hoge: 2 };
match(item1, 'type', matchers); // 1
match(item2, 'type', matchers); // 2
コード全体は以下になります.
参考文献
1 Discriminated Unionともいう 判別可能なUnion型 - TypeScript Deep Dive 日本語版
2 TypeScript: derive map from discriminated union - Stack Overflow