TypeScript には conditional type (以下条件型)という機能があり、以下のような型を定義することができます。
type Foo<T> = T extends Bar ? Hoge : Fuga;
これ自体は x ? y : z
という演算子がある言語を触っている人なら割と直感的に理解できるもので、ジェネリック型 Foo<T>
の型パラメータ T
に与えられた型が Bar
を継承するなら Hoge
、そうでなければ Fuga
ということになります。
「なります」と言っておきながら、私は当初それすらも「え???じゃあ Hoge
とか Fuga
とか直接書けばよくない??」と思ったりしたのですが、さらに混乱させたのが下記のような記法でした。
type Foo<T> = T extends Bar ? Hoge : never;
せっかく条件で分岐しているのに never
って……?なんのために……?と思ったのです。never
は「ありえない値の型」と理解していたので「T が Bar なら Hoge、そうでなければありえない」ってどういうこっちゃ???と。
ようやく意味と頻出パターンがわかってきたので、今日はその内容について説明してみようと思います1。
前置き: そもそも条件型を使う意味とは?
「え???じゃあ Hoge とか Fuga とか直接書けばよくない??」
がフラグだったりするのですが、そもそも type
宣言というのはどんな時に使用するのでしょうか?
type
宣言というのは既にある型に別名を付けたり、既にある型に似た別の型を定義したりするときに使うものです。
// union 型 Piyo を定義している感覚になるが、
// TypeScript においては Hoge | Fuga で既に型として成立しており
// Piyo はその別名をつけているに過ぎない
type Piyo = Hoge | Fuga;
なお、interface
宣言も概ね似た目的のものです。
// { bar: string } と直接書くことができるが、
// 何度も書くのは煩わしいし意図も伝えづらくなるため
// インターフェースに名前を与えて使いまわすことができる
interface Foo {
bar: string
}
つまり、TypeScript での型宣言というのは基本的に「既存の型を全部真面目に書いて回るとめんどくさいし保守性も可読性も悪いから名前を付けてうまいことやるよ」なのです。
これって関数やメソッドと考え方が似ていますよね?そうなってくると、その宣言で条件分岐が使える意味・メリットもわかりやすくなってくるのではないでしょうか?
例えば次に紹介する ReturnType<T>
は「既に関数が定義されており、その戻り値の型を使うという意図がある場合に、戻り値の型を直接書くことも出来るが、既存の関数の名前を使って ReturnType<typeof doSomething>
とした方が意図も明確だし戻り値の型が長いときにも冗長にならない」というメリットがあります。
パターン1: infer で型を取り出すのが目的の場合
infer
を使って型を取り出すことが目的のとき、常に真であるような(あるいは常に真であることしか想定しないような)条件を使うことがあります。これがよくあるパターンです。
例を見ます。
// 型パラメータに与えた関数型の戻り値の型を取り出す型 ReturnType<T> を定義。
// ReturnType<(s: string) => number> とすると number になる。
// 既に定義されている関数を元にその戻り値の型を表したいときに使う(そのまんまの説明ですが…)
type ReturnType<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : never;
なにやらごちゃごちゃしていますが、引数の部分を隠して読むとこうなります。
type ReturnType<T extends (■■) => any>
= T extends (■■) => infer R ? R : never;
型制約が T extends (■■) => any
なので「T
は関数型である」となり、T extends (■■) => infer R ? R : never
は「T
が関数型であるなら戻り値の型を R
として R
、そうでなければ never
(ありえない)」となります。
型制約の時点で extends (■■) => any
としているので「なりたたないとき」は「ありえない」ので、実質 ? R
側しか使わない、だから : never
というわけですね。
じゃあなんで条件なんかつけたの?といえば、infer R
で戻り値の部分の型を型変数に取り出したいからです。
このように「型変数に infer
で型の一部を取り出したい」ために条件型を使うケースは頻出です。
なお、先ほどの例は最初から T
に関数型を渡すことしか想定しません。型制約をつけておけば関数型以外を渡したときにコンパイルエラーにできますが、仮に型制約がなかったとしても、その場合は「条件を満たさない」ということで never
となり使い物になりませんから、型制約は無くても実用できそうです。
type ReturnType<T> = T extends (...args: any) => infer R ? R : never;
先の例が「常に条件が成り立つケース」で、後の例が「条件が成り立つ場合しか想定しないケース」となります。
なお、実際は ReturnType<T>
は標準ライブラリで定義されており以下の定義になっています。
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
型制約をつけたうえで、条件の else 側は : any
になっていますね。: never
にしていない理由がわかりませんでした。。。2
パターン2: Union distribution を発生させるのが目的の場合
こちらは難しい話で、以下のような型が該当します。
type Extract<T, U> = T extends U ? T : never;
パッと見、意味不明かと思います。
これは何をする型かというと、union 型を渡して左の型の要素から右の型の要素に含まれるものだけを残す型です。
type Foo = Extract<"a" | "b" | "c" , "a" | "c" | "e">;
let x: Foo; // "a" | "c"
これがなぜこのように振る舞うのかについては TypeScriptの型初級 に詳しく説明があるのでそちらへ譲りたいと思います。
ポイントは union disitribution を発生させた結果、条件を満たさないものだけが never
になり union 型から消去される、ということです。そのために never
を利用します。
まとめ
以上のように、条件型の中でも片方が never
になるケースというのは、条件分岐自体が目的というよりも、そこに付随する機能の利用(infer や union distribution)が目的であるケースが多いと考えます。
そのためわかりにくいと感じるのだと思いますが、パターンを理解すれば「なるほど、こういうことがしたいのか」と飲み込みやすくなるかと思います。