TypeScriptでGeneric Typeに対するNarrowingをしたかったけど現在のところ対応していなかった

最初にまとめ

現在のTypeScript(2024-09-19時点のバージョン5.6.2)では GenericsControl Flow Analysis は、いい感じには連携しないということを学んだ。

どういうこと?その1

Genericsを使って型安全にやりたいなぁと思って、こんなコードを書いてみてもコンパイルエラーになる。

type Mapping = {
    a: boolean,
    b: string,
}

function getValue<K extends "a" | "b">(key: K): Mapping[K] {
    if (key === "a") {
        return true;
    } 
    return "foo";
}

このコードでやりたいのは「パラメータとして"a"を渡すとboolean型の値を、"b"を渡すとstring型の値を返す」ということ。key === "a"のところでK"a"であることが分かるので、Mapping["a"]にあたるboolean型で返してくれそうなもんだよね。でも、実際は次のようなコンパイルエラーになる。

Type 'true' is not assignable to type 'Mapping[K]'.
  Type 'boolean' is not assignable to type 'never'.

Genericsの型パラメータに対してはNarrowingが行われないから、ということらしい。

Narrowingが行われなかった結果never型になるのは、戻り値の型としてboolean型とstring型の可能性があるから、どちらも満たす必要がある。つまりboolean & stringだからnever型になっている。

参考にした情報

↓これを読んだ。

typescript - Type narrowing for the return type of a function with switch - Stack Overflow

回答者のjcalzさんは、GenericsとNarrowingまわりを調べるとたくさん出てくる。TypeScriptのコントリビュータ。こう書いてある

Currently (as of TypeScript 5.3) generic types and control flow analysis don't play nicely together in TypeScript. If you use a switch/case or if/else statement to check a value of a generic type, this might result in narrowing the type of that value, but it has no effect on the generic type parameter itself.

訳すと

現在のところ(TypeScript 5.3時点では)、TypeScriptのgeneric typesとcontrol flow analysisはうまく連携しない。switch/case文やif/else文を使ってジェネリック型の値をチェックすると、その値の型は絞り込む結果になるかもしれないが、ジェネリック型のパラメータ自体には何の影響もない。

だから、さっきの例でkey === "a"になって、keyの型は"a"にNarrowingされていたとしても、Generic型であるK"a"だと判断されることはなくて"a" | "b"のままだということ。だからneverになる。なるほどなぁ。

じゃあどうするの?

この場合は、こういう風に実装すればいい。

type Mapping = {
    a: boolean,
    b: string,
}

function getValue<K extends "a" | "b">(key: K): Mapping[K] {
    return {
        a: true,
        b: "foo",
    }[key]
}

これはコンパイルがとおる。なるほどなぁ。

どういうこと?その2

なんでこんなことを考え始めたのかと言うと、同僚に「こういうのってコンパイルとおせるのかな?」って聞かれて、なにそれ面白そうってなったからなのだ。

type MyUnion =
    | { tag: "a", value: boolean }
    | { tag: "b", value: string }

const values: MyUnion[] = [
    { tag: "a", value: true },
    { tag: "b", value: "foo" },
]

type MyMapping = {
    [I in MyUnion as I["tag"]]: I["value"]
}

function getValue<T extends MyUnion["tag"]>(t: T): MyMapping[T] {
    for (const v of values) {
        if (v.tag === t) {
            return v.value;
        }
    }
    throw new Error("Unknown tag.");
}

確かにgetValue()return v.value;のところでエラーが出る。

Type 'string | boolean' is not assignable to type 'MyMapping[T]'.
  Type 'string' is not assignable to type 'MyMapping[T]'.
    Type 'string' is not assignable to type 'never'.(2322)

お。さっき見たエラーと似てるぞ(実際に僕が調べたのはその2が先で、それをシンプルにして試してみたのがその1だから順番が逆なんだけどね😁)

やりたいこと

この例でやりたいことは「タグ付きユニオンのタグを指定して、型安全に値を取り出したい」ということ。

↓ タグが"a"だったらboolean型、タグが"b"だったらstring型の値を持つタグ付きユニオン型

type MyUnion =
    | { tag: "a", value: boolean }
    | { tag: "b", value: string }

↓その値はこういう感じで定義される。APIを呼び出した結果として取得することを想定

const values: MyUnion[] = [
    { tag: "a", value: true },
    { tag: "b", value: "foo" },
]

↓その値の配列に対してタグを指定して値を取り出したいので、MyUnionからMyMappingを作る

type MyMapping = {
    [I in MyUnion as I["tag"]]: I["value"]
}

これでタグを渡せばその値の型が取得できる。例えばMyMapping["a"]はboolean型になる。これを使ってgetValue()を定義している↓

function getValue<T extends MyUnion["tag"]>(t: T): MyMapping[T] {
    for (const v of values) {
        if (v.tag === t) {
            return v.value;
        }
    }
    throw new Error("Unknown tag.");
}
  • MyUnion["tag"]MyUniontagの型。この場合"a" | "b"になる
  • T<T extends MyUnion["tag"]>だからMyUnion["tag"]を満たす型パラメータ
  • なので引数t: Tには、この場合"a""b"を渡せる("c"は渡せない)。それによってTの型がきまる

つまり、こうなる

この実装に対するコンパイルエラーをなくせないかな?というのが質問。

その1をふまえて考える

v.tag === tしてるから"a"を渡せばboolean型だって分かりそうなもんだけど、その1で見たとおりTypeScriptはGeneric TypeのNarrowingをしないから、T"a" | "b"のままなのだ。つまり戻り値はどっちのケースも満たす型じゃないといけないので、結局never型になってしまう。

なるほどなぁ。

みんなも同じことを考えている

ので、例えばこんな質問がある

そしてjcalzさんがたくさん回答してる。安心感。

じゃあ、このケースはどう対応するの?

↑の2つのstackoverflowの前者の方でjcalzさんが回答している。このケースはキレイには対応できない。関数のオーバーロードを使うか、タイプアサーションを使う必要がある。どちらにしても気をつけて使わないといけない。

自分の好みを先に言っておくと、僕は関数のオーバーロードよりはタイプアサーションの方が好きかな。

関数のオーバーロード

function getValue(t: "a"): boolean;
function getValue(t: "b"): string;
function getValue<T extends MyUnion["tag"]>(t: T) {
    for (const v of values) {
        if (v.tag === t) {
            return v.value;
        }
    }
    throw new Error("Unknown tag.");
}

こんな風に定義をするとコンパイルがとおるし、呼び出す側もgetValue("a")ってするとboolean型の値が返されて、getValue("b")だとstring型の値が返されるということになる。

ただ、間違えて"b"のときにもtrueを返すように実装してしまってもコンパイルはとおるので注意が必要。↓こんな感じで間違えた実装をしてしまうと

function getValue(t: "a"): boolean;
function getValue(t: "b"): string;
function getValue<T extends MyUnion["tag"]>(t: T) {
    return values[0].value;
}

↓こんな風に「引数が"b"のときはstring型の値が返されるよ!」って言ってくれるのに

↓「だからvalueBはstring型だよ!」って教えてくれてるのに

実際にはtrueが返される。えー。TypeScriptが嘘をつくときがいちばん分かりにくいバグになるので、注意して実装する必要がある。

タイプアサーション

タイプアサーションだとこんな感じ。

function getValue<T extends MyUnion["tag"]>(t: T): MyMapping[T] {
    for (const v of values) {
        if (v.tag === t) {
            return v.value as MyMapping[T];
        }
    }
    throw new Error("Unknown tag.");
}

これでもコンパイルエラーは出なくなるけど、これもオーバーロードと同じでTypeScriptに「これで信じて!」って型を渡していることになるので、実装を間違えると違う型の値を返してしまう。↓こんな風に

function getValue<T extends MyUnion["tag"]>(t: T): MyMapping[T] {
    return values[0].value as MyMapping[T];
}

そしてさっきと同じ状態になってしまう。

まとめ

現在のTypeScript(2024-09-19時点のバージョン5.6.2)では GenericsControl Flow Analysis は、いい感じには連携しないということを学んだ。

その1の方はプロパティを使ったワークアラウンドが使える。

その2の方は関数のオーバーロード定義またはタイプアサーションが使えるけど、TypeScriptによる型チェックではなくなるので実装に気をつける必要がある。

という感じだった。

関連するTypeScriptのIssue

みんなも「対応してほしいなぁ」って思っているみたいでIssueがあがっている(jcalzさんがstackoverflowの回答の中で教えてくれている)

そして、まだDraftだからどうなるか分からないけど↓これが、Narrowingに対応しようとしているPRみたい。楽しみ

おもしろかったー!

ところで

先週これに関係して遊んでたやつもよかったらどうぞ!

bufferings.hatenablog.com