TSの条件型の条件部に型パラメータが突っ込まれたときの挙動
TypeScriptの条件型は、このような見た目をした型です。
type IsString<T> = T extends string ? string : never;
この型は、型パラメータT
がstring
型に割り当て可能な型であれば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>
の振る舞いについては解説の必要はないでしょう。ジェネリック関数f
はT
型の引数を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つの条件型を作りました。結果としては、ExtendsUnknown
はT extends unknown ? true : false
となり、ExtendsUnknownRecord
はtrue
となりました。つまり、前者においては「まだ決まっていないから判断を保留するよー」という振る舞いをしているのに、後者は「もうT
がなんなのかはわかっているのでtrue
!」と判断してしまっています。興味深いです。
他にも、Arg[] extends unknown[] ? true : false
もtrue
となります。型構築子に食わせるとなぜか具体的な型として扱われてしまうのでしょうか?
また、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
これは条件型がユニオンで分配される挙動に関係しているのではないかと思います。分配が行われる条件として、
T extends ...
のように、extends
の前で型がそのまま使われる必要があり、{ arg: Arg } extends ...
のようなスタイルでは分配が行われません。分配が行われない場合、は
と置き換え可能ですが、分配が行われる場合はそうとは限りません。たとえば、
という型があったとして、このTにユニオン型が入ると、2つの型が返す結果は異なります。
これは
KeyOf2<Union>
が分配により、次のように解釈されるためです。このような挙動があるため、
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つの挙動の違いは説明できますね
ただ、分配が行われないとして、
{ arg: Arg } extends Record<string, string>
が「常に偽な条件」と判断されてしまうことには別の説明が必要に思われるのですがどうなんでしょう?そうですね。
T extends ...
の形式だけが特別なケースで型の解決が保留されて、他の形式のときは条件が確実に真な場合のみ真、それ以外の場合は偽として型を解決してしまっているように見えますね。後者の挙動はなんでそうなるのかちょっと分からないです…正式にバグ認定されたっぽいです