はじめに
TypeScript を使っていて never
型を見かけたとき、
「なんか怖い」「どうせエラー用でしょ」「なんでそれが必要なの?」と感じたことはありませんか?
でも実は、TypeScriptで型安全な開発を行う上で、知っていると圧倒的に安心できる存在
—— それは never
型なのです。
本記事では、「そもそも never
型とは何か?」から始まり、そのメリットやユースケース、さらにユースケースごとの他の書き方との比較まで徹底解説したいと思います!
できるだけ丁寧に・分かりやすくご紹介していますので良かったら見てみてください!!
そもそも never
型とは?
never 型は、絶対に値が存在しない型を意味します。
関数であれば「戻ってこない」、変数であれば「絶対に値が代入されない」という意図を持ちます。
たとえば、次のような使い方をします。
const fail = (message: string): never => {
throw new Error("なんかエラーが出たよー:" + message);
}
この fail
関数の返り値は never
型となります。
使用感としてはこんな感じですね。
const someFunc = (key: string) => {
switch (key) {
case 'hoge':
return 'hoge'
case 'fuga':
return 'fuga'
case 'piyo':
return 'piyo'
default:
return fail('keyがおかしい値だよー') // ← ここ!!
}
}
// >>> なんかエラーが出たよー:keyがおかしい値だよー
この fail
関数の返り値が never
である理由は、この関数が呼ばれた時に必ず例外をスローし、それ以降の処理に絶対に到達しないことを明示的に型で表現しているからです。
つまり、return
もせず、例外で止まる・無限ループで抜けないなど、「制御が戻らない関数」の戻り値として never
型を指定することで、呼び出し元では以降の処理が発生しないことを型レベルで保証できるのです。
よくある使い所
never
型は単独で表に出ることはあまりありませんが、型検査・静的解析における最終防衛ラインとして、コードの信頼性と堅牢性を支えてくれるとても重要な存在です。
以下が主な使い所になります。
- ① 分岐漏れ検出(網羅性チェック)
- ② ユニオン型差分の静的チェック
- ③ 到達不能処理の明示
- ④ 再帰型の終了条件に使う
- etc...
以降の記事では各ユースケースについて解説し、別の書き方との優位性の比較をしたいと思います。
ユースケース別の使い方と優位性
① 分岐漏れ検出(網羅性チェック)
type Status = "loading" | "success" | "error" | "idle"
const showMessage = (status: Status): string => {
switch (status) {
case "loading":
return "読み込み中"
case "success":
return "成功"
case "error":
return "エラー"
default: // ← ここ!!!
const _: never = status
throw new Error(`未対応の状態: ${_}`)
}
}
ステータスが複数ある場合に、switch文で制御するケースって結構あると思います。
このように default
文で never
型を使えば、例えば後からStatus
に idle
を追加して処理を忘れた場合でも、型エラーで即検出できます。
ステータス値の種類数が多い場合なんかは、自力で書いていくとミスを誘発してしまうため、地味に助かったりします。
⛔ 他の書き方との比較
const showMessage = (status: Status): string => {
if (status === "loading") return "読み込み中"
if (status === "success") return "成功"
if (status === "error") return "エラー"
// 'undefined'のエラーは出ても、`idle`に関するエラーでTSは怒らない!
}
コメントでも示しているように、返り値の型で return
が抜けていることは検出されます。
しかし、将来的な型の追加漏れ (idle
) については検出ができないため、never
型の使用に優位性がありそうです。
② ユニオン型差分の静的チェック
TypeScript でユニオン型の差分を検出したいとき、never
型を使えば仕様と実装のズレを静的に検知できます。
この仕組みは、型レベルのテストのように使えるため、保守性の高い型設計に非常に役立ちます。
type A = "a" | "b"
type B = "a" | "b" | "c"
// A,B間で差分を抽出(同じ型であればnever型を返却)
type Diff = Exclude<B, A> // 'c'
// ここで差分がなければ、never型になるはず!
type AllKnown = Diff extends never ? true : false
// 型アサートによる静的検証
type AssertTrue<T extends true> = T
type _check = AssertTrue<AllKnown> // 'c' があるので型エラー!
実際の使用例
例えば、OpenAPI で返却されるレスポンスの型と、既存のコードで定義されている型が想定通りであることをコンパイル時にチェックしたい場合などはあると思います。
この時、以下のように定義することで、今後型の追加があったときに自動でエラー検出できるようになります。
type ExpectedKeys = "id" | "name"
// 実際に返ってきた API レスポンスの型
type Response = {
id: number
name: string
extra: string // ← 本来あってはならないキー
}
// 差分(存在してはいけないキー)を抽出
type UnexpectedKeys = Exclude<keyof Response, ExpectedKeys>
// 差分があれば never にならない → 型エラーにする
// → 静的に "extra" という意図しないフィールドの存在を検出できる!
// → コンパイルエラーになる!!!
type _assertNoUnexpected = AssertTrue<
UnexpectedKeys extends never ? true : false
>
⛔ 他の書き方との比較
type Response = {
id: number
name: string
extra: string // ← これはAPI側にしかない余計な情報!
}
type Expected = {
id: number
name: string
}
// でもこれは通ってしまう!
const data: Expected = {
id: 1,
name: "Taro",
}
このように、余計なプロパティが存在しても代入時に検出されないことがあります。
一方、Exclude + never 型チェックを組み合わせれば...
type ExtraKeys = Exclude<keyof ApiResponse, keyof Expected> // 'extra'
type AllKnown = ExtraKeys extends never ? true : false
type _checks = AssertTrue<AllKnown> // 型エラーが出てズレに気付ける!
明示的に型で差分を検出することで、見落としを防げるのが never
型のすごさです。
③ 到達不能処理の明示
ある関数が「絶対に戻ってこない」と明言できる場面では、never
型を使うことで TypeScript にそれを伝えることができます。
こうすることで、後続のコードで型の絞り込みや型安全な処理が保証されるようになります。
本記事冒頭の fail
関数がまさにこの働きをしています。
const fail = (message: string): never => {
throw new Error("なんかエラーが出たよー:" + message);
}
実際の使用例
fail
関数の他には、例えばAPIで取得した値が null
のときに、処理を即時終了したいケースがあります。
const assertIsDefined = <T,>(
value: T | null,
message?: string
): asserts value is T => {
if (value == null) {
throw new Error(message ?? "valueがないよー")
}
}
const getUser = (user: User | null) => {
assertIsDefined(user, "ユーザーがいません")
// ↓↓↓ ここから先、userは「nullでない」と型推論される!
return user.name
}
このように、到達不能となるケースで never
型を活用すれば、型絞り込みがより正確かつ安全に行えるのです。
⛔ 他の書き方との比較
const getUser = (user: User | null): User => {
if (user === null) return undefined as any // ← 型的にごまかしてるだけ
return user
}
対して、 return undefined
などで処理を止めた場合はどうでしょうか?
TypeScript からすると、user
が null
であった場合に、これだけで「処理を止める!」とは判断できません。
たとえば、もし fail()
の戻り値が void
や any
の場合はどうでしょう?
TypeScript は「戻ってくる可能性もあるかも…?」と判断して、以降のコードの型推論がちょっと弱くなったり、IDEの補完が曖昧になったりすることもあります。
この点、never
型は「この関数が return
されることは絶対にない!」という確定情報なので、型の絞り込みに対しての信頼性が一段上がるというわけです。
だからこそ、「ここには絶対に到達しない」と TypeScript に教えるための手段として、never
型が重要なんです!
④ 再帰型の終了条件に使う
再帰的な型定義を書くときにも、never
型は意外と頼りになります。
たとえば、配列のネスト構造を平坦化するような型を考えてみましょう。
type Flatten<T> = T extends (infer U)[] ? Flatten<U> : T
type Test = Flatten<number[][][]> // number
この Flatten
型は、配列の中にある配列をどんどん再帰的に展開して、最終的な要素の型を返してくれる型です。
しかし、 Flatten
の T
に対して、空配列([]
)が入った場合はどうでしょうか?
Flatten<[]>
// → [] extends (infer U)[] ? Flatten<U> : []
// → never extends (infer U)[] ? Flatten<U> : never
// → never
T
には空配列([]
)が入ると思います。
しかし、U
は推論できないため、 never
型となってしまいます。
「空だから中身の型が不明 → U = never」 ということですね!
つまり、Jsonなどの再帰的なデータ構造を処理する際、その中に空配列が含まれていると、返却される型が never
型に汚染されてしまうのです。
そのため、以下のように「never
型は flatten
対象にしないでね」という制御を加えることで、安全に再帰的な型展開を行うことができるようになるのです。
type SafeFlatten<T> = [T] extends [never]
? never
: T extends (infer U)[]
? SafeFlatten<U>
: T
「そもそも展開すべき型じゃなかった」「型の中身が存在しない」などのときに、再帰暴走を防ぐ便利なブレーキになるのです!
メリット
- 明示的に
never
型を返すことで、「どこまで展開してよいか?」という再帰の打ち止め条件を型システム側が認識できるようになる -
never
型を使わないと、型展開が続いてしまい、複雑な型計算に失敗したり、意図しない型結果が出てしまう可能性がある -
never
型を挿入することで、安全な終了処理をとなり、再帰する型の安定性が一気に増す
おわりに
ここまで読んでくださって、本当にありがとうございました!
never
型は TypeScript の中でも見落とされがちな存在ですが、
その恩恵は極めて大きく、型の正確さ・堅牢さ・変更耐性すべてに貢献してくれます。
私自身、日々TypeScriptを書いていても、
- 分岐漏れチェックを入れるのはやりすぎ?
- 型の差分チェックってほんとに必要?
- return 型つけてるし、switch の default いらなくない?
…などなど、毎回のように迷ってしまうことがあります。
今回「never
型」という切り口で整理することで、
「どのようにすれば堅牢なTypeScriptコードがかけるのか?」の判断軸が少し見えてきたような気がします。
やっぱり、型って奥が深いですね……(*´-`)
あらためて、最後まで読んでくださりありがとうございます。
もし記事が参考になったら、「いいね」と「ストック」をしてもらえるとすごく励みになります!
また、内容に誤りや気になる点があれば、遠慮なくご指摘していただけると嬉しいです!
他にもいろいろな記事を投稿しているので、もしよかったら見てみてください!
ではでは!
参考
記事を執筆するにあたって、以下の資料を参考にさせていただきました。
先人たちの知見に感謝です!