4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

TypeScript の型定義における ○○ ? △△ : never とはなんなのか?

Posted at

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;

lib.es5.d.ts

型制約をつけたうえで、条件の 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)が目的であるケースが多いと考えます。

そのためわかりにくいと感じるのだと思いますが、パターンを理解すれば「なるほど、こういうことがしたいのか」と飲み込みやすくなるかと思います。

参考記事

  1. 他にもパターンがあれば教えてください

  2. Parameters 側の定義は : never のようです……:thinking:

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?