Posted on

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 の型推論を任せることも出来ますが, matchersHogeFuga を網羅しているかは保証されません. なので 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]] の引数の型が共用型のある具体的な型であるのに対し, itemT 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

コード全体は以下になります.

Typescript playground

参考文献

1 Discriminated Unionともいう 判別可能なUnion型 - TypeScript Deep Dive 日本語版

2 TypeScript: derive map from discriminated union - Stack Overflow