TSの条件型の条件部に型パラメータが突っ込まれたときの挙動

2024/07/24に公開4

TypeScriptの条件型は、このような見た目をした型です。

type IsString<T> = T extends string ? string : never;

この型は、型パラメータTstring型に割り当て可能な型であればstring型を返し、そうでなければnever型を返します。では、Tの位置に与えられた型がまた型パラメータを含む場合はどうなるのでしょうか?

そもそものシチュエーションが想像しにくいと思うので、具体例を見ながら説明していきましょう。

type Conditional<T> = T extends Record<string, string> ? { _true: T } : { _false: T }

const f = <T,>(arg: T): Conditional<T> => arg as any
const wrapF = <T,>(arg: T) => f({ arg })

Conditional<T>の振る舞いについては解説の必要はないでしょう。ジェネリック関数fT型の引数をConditional<T>に型付けして返す関数で、危険な操作をしていますが説明のために拵えたものなので問題はありません。さらに、ジェネリック関数wrapFは引数をオブジェクトにして返してfに適用しているだけの関数です。これも挙動の説明のためであった特に実用上の意味はありません。

さて、次にこのwrapFと同じ型を受け取る関数を定義します。

declare function receiveWrapF(fun: WrapF): void

関数の実装には興味がないのでdeclareで宣言しています。では、この関数にwrapFと同じ見た目のアロー関数を渡すとどうなるでしょうか?

receiveWrapF((arg) => f({ arg }))

実はこれはコンパイルエラーになります。なぜなら、wrapFの型が<T>(arg: T) => Conditional<{ arg: T }>なのに対し、(arg) => f({ arg })の戻り値の型は{ _false: { arg: T; }; }となっているからです。
これが意味するのは、(arg) => f({ arg })の型付けにおいては、{ arg: T }Record<string, string>に割り当てることができないと判断されているということです。他方、おそらくwrapFの場合はまだTが具体的に定まっていないのでT extends Record<string, string>という判断式も留保され、戻り値の型はConditional<{ arg: T }>のままとなっているのでしょう。

(arg) => f({ arg })の中でargの型がどのように扱われているかもう少し調べてみましょう。

receiveWrapF((arg) => {
    type Arg = typeof arg // つまり`T`
    type ExtendsUnknown = Arg extends unknown ? true : false
    type ExtendsUnknownRecord = { arg: Arg } extends Record<string, unknown> ? true : false
    return f({ arg })
})

argの型を使って2つの条件型を作りました。結果としては、ExtendsUnknownT extends unknown ? true : falseとなり、ExtendsUnknownRecordtrueとなりました。つまり、前者においては「まだ決まっていないから判断を保留するよー」という振る舞いをしているのに、後者は「もうTがなんなのかはわかっているのでtrue!」と判断してしまっています。興味深いです。
他にも、Arg[] extends unknown[] ? true : falsetrueとなります。型構築子に食わせるとなぜか具体的な型として扱われてしまうのでしょうか?

また、wrapFの方も条件節が評価される場合もあります。例えば、Conditionalの条件節をT extends Record<string, unknown>にすると、wrapFの戻り値の型は{ _true: { arg: T } }となります。これは、Tがどんな型であろうと{ arg: T }Record<string, unknown>に割り当て可能なので、Tの決定を待たずして判断できるからでしょう。

挙動はつかめてきましたが、なぜこのような振る舞いをするのかは定かではありませんが、筆者もこれ以上は深追いしていないのでなんとも言えません。最後に回避方法だけを述べて終わりにしようと思います。

receiveWrapF(wrapF)

const wrapF2 = <T,>(arg: T) => f({ arg })
receiveWrapF(wrapF2)

wrapF自体はもちろん渡せますし、同じシグネチャの関数を変数に割り当ててから渡すこともできます。

ソースコードはTypeScript Playgroundで確認できます。お疲れ様でした。

Discussion

Kenji ImamulaKenji Imamula

これは条件型がユニオンで分配される挙動に関係しているのではないかと思います。分配が行われる条件として、T extends ...のように、extendsの前で型がそのまま使われる必要があり、{ arg: Arg } extends ...のようなスタイルでは分配が行われません。分配が行われない場合、

type Foo = 常に真な条件 ? A : B;

type Foo = A;

と置き換え可能ですが、分配が行われる場合はそうとは限りません。たとえば、

type KeyOf1<T> = keyof T;
type KeyOf2<T> = T extends unknown ? keyof T : never;

という型があったとして、このTにユニオン型が入ると、2つの型が返す結果は異なります。

type Union = { a: number; b: number } | { a: number; c: number };
type KeyOf1Union = KeyOf1<Union>; // type KeyOf1Union = "a"
type KeyOf2Union = KeyOf2<Union>; // type KeyOf1Union = "a" | "b" | "c"

これはKeyOf2<Union>が分配により、次のように解釈されるためです。

({ a: number; b: number } | { a: number; c: number }) extends unknown ? keyof T : never
= ({ a: number; b: number } extends unknown ? keyof T : never) |
  ({ a: number; c: number } extends unknown ? keyof T : never)
= (keyof { a: number; b: number }) | (keyof { a: number; c: number })
= "a" | "b" | "c"

このような挙動があるため、type Foo<T> = A;type Foo<T> = T extends unknown ? A : never;に書き換えることは、分配を強制的に発生させるための一般的なテクニックになっています。逆に、type Foo<T> = T extends U ? A : B;のような型があったときに、分配が発生すると困る場合は、type Foo<T> = [T] extends [U] ? A : B;のように型を[]で囲って分配を抑制したりします。

ほとけほとけ

おお、ありがとうございます!確かにそれでこの2つの挙動の違いは説明できますね

type ExtendsUnknown = Arg extends unknown ? true : false
type ExtendsUnknownRecord = { arg: Arg } extends Record<string, unknown> ? true : false

ただ、分配が行われないとして、{ arg: Arg } extends Record<string, string>が「常に偽な条件」と判断されてしまうことには別の説明が必要に思われるのですがどうなんでしょう?

// falseになる
type ExtendsStringRecord = { arg: Arg } extends Record<string, string> ? true : false
Kenji ImamulaKenji Imamula

そうですね。T extends ...の形式だけが特別なケースで型の解決が保留されて、他の形式のときは条件が確実に真な場合のみ真、それ以外の場合は偽として型を解決してしまっているように見えますね。後者の挙動はなんでそうなるのかちょっと分からないです…