never が好き

この記事は はてなエンジニア Advent Calendar 2025 の 4 日目の記事です.

id:susisu です. TypeScript の never 型がたいへん奥ゆかしいので見ていってください.

基礎編

まずは never 型がどういったものなのかを見てみましょう.

値のない型として

例えば number 型に対しては 42, 3.14, NaN など , string 型に対しては "", "Hello" などのように, 一般的な型にはその型が付けられる値が存在します.

一方で never 型にはそういった値が存在しません (こういった型は一般にボトム型などと呼ばれたりします). never 型の変数には何を代入しようとしてもエラーになります.

const x: never = 42; // エラー

undefinednull も「値が存在しない」と説明されることがありますが, これらはせいぜい目的に対して利用可能な値 (具体的な数値やオブジェクトなど) がないというだけで, undefinednull 自体は歴とした値です. そのため当然 never 型の変数には代入できません.

const y: never = undefined; // エラー
const z: never = null; // エラー

任意の型の部分型として

S の値を型 T の値として扱っても問題がないとき, 型システムが「型 S は型 T の部分型である」と判断してこのような扱いを許すことがあります. 例えば { id: string; name: string } 型の値は { id: string } 型の値として扱っても普通は問題ないので, TypeScript のコンパイラは前者を後者の部分型とみなします.

さて never 型の値はというと存在しないので, どう扱っても問題が起きません (そもそも扱うことがありません). そのため TypeScript のコンパイラは任意の型 T に対して「never は型 T の部分型である」と判断します.

実際に never 型の値が存在すると仮定して declare で宣言してあげると, その値を任意の型の値として扱えることが確認できます.

declare const x: never;

const y: number = x; // OK
const z: string = x; // OK

空のユニオン型として

TypeScript には number | string のようなユニオン型が存在して, JavaScript 時代からありがちな「number もしくは string」といった曖昧な値もうまく扱うことができます.

ところで never 型には値が存在しないことから「空のユニオン型」とみなすことができて, 実際に TypeScript のコンパイラはそのような扱いをしてくれます. またこれは型の演算 |単位元ということでもあり, 任意の型 T に対して T | never = never | T = T です. 「number もしくは never の値」は要するに「number の値」であるということですね.

type X = number | never;
//   ^? type X = number

応用編

never 型が何であるかについて説明してきましたが, TypeScript や never 型に馴染みのない方にとっては, おそらくこれだけでは使い方の見当がつきづらいかと思います. ということで具体的な使い方をいくつか紹介します.

絶対に値を返さない関数

例えば常に無限ループしたりエラーを throw したりする関数は値を返しません. こういった関数の戻り値として never が使えます. シグネチャから振る舞いが推測できて便利ですね.

function infiniteLoop(): never {
  while (true) {}
}

function throw_(error: Error): never {
  throw error;
}

JavaScript や TypeScript の throw は式ではありませんが, 上記のような throw_ 関数を定義しておくと throw 式のようなものとして便利に使えます. 任意の型 T に対して T | never = T なこともあり, 下記の例の foo に対してはきちんと options.foo が存在する時の型が推論されます.

const foo = options?.foo ?? throw_(new Error("'foo' is required"));

また関数が絶対に値を返さないということは, その関数を呼び出した後のコードは絶対に実行されないということなので, コンパイラが気を利かせて型を絞り込んでくれたりします.

declare const x: number | string;

if (typeof x === "number") {
  infiniteLoop();
  // ここにはもう来ないはず
}

const y: string = x; // OK

条件分岐の網羅性チェック

上の例でもしれっと登場していますが, TypeScript にはフロー解析があって, ifswitch などで変数の型を絞り込むような条件を書くと, 実際にその変数が絞り込まれた後の型として使えるようになります.

declare const x: number | string;

if (typeof x === "number") {
  const y: number = x; // OK
} else if (typeof x === "string") {
  const z: string = x; // OK
} else {
  throw new Error("unexpected value");)
}

ではこの最後の else の中で x の型はどうなっているでしょうか? number でも string でもなく他に可能性がないということは, そう never ですね.

こういった ifswitch の条件分岐が全てのパターンを網羅しているかをチェックするのに never が使えます. 例えば以下のように never 型の変数に x を代入するコードを書いてあげると, x に対する条件が網羅されて他の可能性が残っていないことを確認できます.

if (typeof x === "number") {
  // ...
} else if (typeof x === "string") {
  // ...
} else {
  const _: never = x; // OK
  throw new Error("unexpected value");
}

もし if が漏れているとこの代入文はコンパイルエラーになるので, 実行前に誤りに気がつけます.

if (typeof x === "number") {
  // ...
} else {
  const _: never = x; // エラー
  throw new Error("unexpected value");
}

式の型を確認するための構文 satisfies を使っても同じことができます.

if (typeof x === "number") {
  // ...
} else if (typeof x === "string") {
  // ...
} else {
  x satisfies never;
  throw new Error("unexpected value");
}

絶対に呼び出せない関数

先ほどの絶対に値を返さない関数とは逆に, 関数の引数に never を使うことで, 絶対に呼び出せない関数が作れます.

function nonCallable(x: never): void {}

notCallable(42); // エラー

これも使い所があって, 上記のように網羅性チェックをしたはずなのに万が一変な値が混入してしまった時のエラー処理を共通化するときなんかに便利です. 下記の unreachable は通常は絶対に呼び出せないはずですが, 変数が never 型ということになっている時に限っては呼び出すことができます. ついでにこれは絶対に値を返さない関数でもありますね.

function unreachable(value: never): never {
  console.error("'unreachable' called", value);
  throw new Error("'unreachable' called");
}

declare const x: number | string;

if (typeof x === "number") {
  // ...
} else if (typeof x === "string") {
  // ...
} else {
  unreachable(x);
}

また型レベルプログラミングと組み合わせると, 引数が特定の条件を満たす時のみ呼び出せる関数を作ることができます. 詳しくは以下の記事を参照.

絶対に特定のパターンにならない型

下記のような成功もしくは失敗を表す型 Result<T, E> があったとしましょう.

type Result<T, E> =
  | { isOk: true; value: T }
  | { isOk: false; error: E };

では常に成功する関数 alwaysSuccess() の型はどうするとよいでしょうか?

以下のようにジェネリクスを使っても良いのですが, デフォルトでエラーの型 Eunknown が推論されてしまったり, 一度変数に代入するなどして E が決まってしまうと変更できない (値としては常に成功なので関係ないはずなのに) など, 使い勝手はあまり良くありません.

function alwaysSuccess<E>(): Result<number, E> {
  return { isOk: true, value: 42 };
}

const r = alwaysSuccess();
//    ^? const r: Result<number, unknown>

const s: Result<number, string> = r; // エラー

ということで never を使いましょう. never の値は存在しないので, この場合 Result<T, never> の値は常に成功を表すことになります. そして never はあらゆる型の部分型なので, 適当な型に対して自由にアップキャストができます.

function alwaysSuccess(): Result<number, never> {
  return { isOk: true, value: 42 };
}

const r = alwaysSuccess();
//    ^? const r: Result<number, never>

const s: Result<number, string> = r; // OK

ちなみに成功時だけを表す型 Ok<T> が独立して定義されているようなケースであれば, never を使わずに以下のようにするのでも良いです.

type Ok<T> = { isOk: true; value: T };

function alwaysSuccess(): Ok<number> {
  return { isOk: true, value: 42 };
}

型レベルプログラミングにおけるエラー

TypeScript の型レベルプログラミングにおいては throw のようなエラーの機構はないので, なんらかの型を使ってエラーを表現することになります. このエラーというのは典型的には「未定義」や「解なし」と捉えられるので, 空のユニオン型という点で意味的な相性の良い never が使われがちです.

type Head<XS extends unknown[]> = XS extends [infer Y, ...infer _YS] ? Y : never;

type A = Head<[1, 2, 3]>;
//   ^? type A = 1
type B = Head<[]>;
//   ^? type B = never

ただし型レベルのコードに対応する実行時のコードがある場合は, never という型と実行時の挙動が矛盾しないように注意しましょう. 例えば実行時のコードでは「未定義」や「解なし」の場合に undefinednull も使われがちですが, これらは当然 never 型の値ではありません. 戻り値の型が never の関数に対応する実行時の挙動は, 上で紹介した通り値を返さない, つまり throw か無限ループです.

ところで型レベルプログラミングといえば, never 型かどうかを判定する型 IsNever<T> を書こうとした人は全員, なぜか never が返ってくるという失敗をしたことがあります (要出典). never が空のユニオン型であるということと分配法則を思い出すと当然の挙動ではありますが, 初見だとびっくりしますよね.

type IsNever<T> = T extends never ? true : false;

type A = IsNever<never>;
//   ^? type A = never

正しくはこう.

type IsNever<T> = [T] extends [never] ? true : false;

type A = IsNever<never>;
//   ^? type A = true

never が大好き

皆さんも never 型が好きになりましたか?

Next.js でよくある一覧 + 詳細画面を作る

あの日見たパターンの名前を僕たちはまだ知らない.

よくある一覧 + 詳細画面を作りたい

例えば TODO アプリで, /todo にアクセスしたらタスクの一覧を, /todo/42 にアクセスしたら一覧は表示したまま ID = 42 のタスクの詳細を表示する, というよくあるパターンの画面を作りたい. 世の中の実例としては Asana や, URL の形は異なりますが GitHub の Projects なんかがこういう感じですね.

TODO アプリのスクリーンショット. /todo ではタスクの一覧が表示され, /todo/42 ではタスクの一覧と詳細が表示されている
/todo で一覧, /todo/42 で一覧 + 詳細

技術的には要するに SPA なのでやればできるはずなんですが, これを Next.js (App Router) 上で作るにはどうしたら良いかという話をします.

素朴な実装は微妙

Next.js はファイルシステムベースのルーティングを行います. ということで最も素朴には以下のようにファイルを配置すれば目的のものが実装できそうです.

todo
|-- page.tsx     : /todo に対応するページ (一覧)
\-- [id]
    \-- page.tsx : /todo/42 などに対応するページ (一覧 + 詳細)

これはぱっと見ではうまく動くようにも見えるのですが, 実際にはパスが変わるたびに一覧部分のコンポーネントが再マウントされ, スクロール位置や内部状態がリセットされてしまって使い勝手に難があります.

ちなみに以下のように optional catch-all segments を使っても同様の理由で微妙です.

todo
\-- [[...slug]]
    \-- page.tsx : パスに応じて一覧もしくは一覧 + 詳細

layout.tsx を使った実装

layout.tsx はパスが変化しても再マウントされないため, この中で一覧を描画し, page.tsx には詳細の表示のみを任せるという形にすれば, 上記の素朴な実装の問題を解消できます.

todo
|-- layout.tsx   : 画面全体のレイアウト + 一覧を表示
|-- page.tsx     : 何も表示しない
\-- [id]
    \-- page.tsx : 詳細をダイアログ等で表示

これ自体はシンプルで良いのですが, 強いて言えばファイル構成から各ファイルの役割が読み取りづらいのと, layout.tsx が複数の役割を持ってしまっていてやや fat な印象がありますね.

Parallel Routes

ところで Next.js には Parallel Routes という機能があって, これを使うと一つのパスに対して複数のページコンポーネントを描画できます.

例えば以下のようにファイルを配置すると, /todo にアクセスされたときに todo/page.tsxtodo/@foo/page.tsx の両方が描画されます.

todo
|-- layout.tsx
|-- page.tsx
\-- @foo
    \-- page.tsx

描画された各コンポーネントの画面への配置は layout.tsx で行います. 通常であればページコンポーネントの描画結果は children という名前で受け取れますが, ここではそれに加えて @foo 以下のページコンポーネントの描画結果が foo という名前で受け取れます. 最もシンプルには以下のようなイメージです.

// layout.tsx
export default function Layout({ children, foo }: LayoutProps<"/todo">) {
  return (
    <>
      {children}
      {foo}
    </>
  );
}

Parallel Routes を使う場合, パスに対応する page.tsx はどこかに 1 つでもあればよいです (1 つもなければそのパスは Not Found になります). page.tsx がない場合は, もし default.tsx があればそれがハードナビゲーション時に代わりに描画されます.

例えば以下のファイル配置では @foo 以下に page.tsx はありませんが, /todo/todo/42 などにアクセスしてもエラーになったりはせず, default.tsx の内容が表示されます.

todo
|-- layout.tsx
|-- page.tsx
|-- [id]
|   \-- page.tsx
\-- @foo
    \-- default.tsx

先ほど default.tsx が代わりに使われるのが「ハードナビゲーション時」という説明をしましたが, ここがやや癖のある部分で, page.tsx のないパスにアクセスされた場合の挙動は, ハードナビゲーション時とソフトナビゲーション時で以下のように異なっています.

  • ハードナビゲーション時: deafult.tsx があればそれを描画, なければエラー
  • ソフトナビゲーション時: 元々描画していたコンポーネントがそのまま描画され続ける (再マウントもされない)

Parallel Routes を使った実装

話を一覧 + 詳細画面の実装に戻すと, 上記の通り Parallel Routes にはやや癖がありますが, これを使うことで各ファイルの役割を明示しつつ, 一覧を表示する役割も layout.tsx から分離できます.

具体的にはファイルを以下のように配置します. メイン部分 (以下 @dialog に対して @children と呼びます) の側で default.tsx を使っているのが面白いところですね.

todo
|-- layout.tsx       : 画面全体のレイアウト
|-- default.tsx      : 一覧を表示
\-- @dialog
    |-- page.tsx     : 何も表示しない
    \-- [id]
        \-- page.tsx : 詳細をダイアログ等で表示

順番に挙動を見ていきましょう. まずはハードナビゲーション時は以下のようになります.

  • /todo:
    • @children: page.tsx がないので default.tsx を描画 → 一覧を表示
    • @dialog: page.tsx があるので描画 → 何も表示しない
  • /todo/42:
    • @children: page.tsx がないので default.tsx を描画 → 一覧を表示
    • @dialog: page.tsx があるので描画 → 詳細をダイアログ等で表示

続いてソフトナビゲーション時は以下の通りです. 先ほど説明した通り, page.tsx がない場合はコンポーネントが再マウントもされずそのまま使われるので, 一覧部分のスクロール位置や内部状態がリセットされたりすることはありません.

  • /todo:
    • @children: page.tsx がないので元のまま → 一覧を表示したまま
    • @dialog: page.tsx があるので描画 → 何も表示しない
  • /todo/42:
    • @children: page.tsx がないので元のまま → 一覧を表示したまま
    • @dialog: page.tsx があるので描画 → 詳細をダイアログ等で表示

というわけで目的のものが実装できました. @dialog のように名前をつけたことで各ファイルの役割もわかりやすくなっており, layout.tsx の役割も本来の役割である画面全体のレイアウトのみになっています. 欠点としては, やはり Parallel Routes 自体の挙動がちょっとわかりづらいことでしょうか.

おまけ: Intercepting Routes

Next.js にはさらに Parallel Routes を応用した Intercepting Routes という機能もあり, これを使うとソフトナビゲーションをその名の通り遮って, 通常 (ハードナビゲーション) 時とは異なるページを表示できます.

例えば以下のようにファイルを配置すると, /todo から /todo/42 にソフトナビゲーションした時に, 一覧の表示を維持したまま詳細をダイアログ等で表示できます. /todo/42 を直接開いたりページをリロードするなどしてハードナビゲーションすると, 詳細は全画面で表示されます.

todo
|-- layout.tsx       : 画面全体のレイアウト
|-- page.tsx         : 一覧を表示
|-- [id]
|   \-- page.tsx     : 詳細を全画面で表示
\-- @dialog
    |-- default.tsx  : 何も表示しない
    \-- (.)[id]
        \-- page.tsx : 詳細をダイアログ等で表示

Next.js で一覧 + 詳細表示をする方法を探すと真っ先に Intercepting Routes を使った方法が見つかりがちですが, これはハードナビゲーション時とソフトナビゲーション時で挙動を変えたい場合に使いましょう. 詳細は常にダイアログで表示したいといったように, 挙動をナビゲーションの方法によらず統一したい場合は Intercepting Routes は必要ありません.

まとめ

よくある一覧 + 詳細画面の実装方法をいくつか見てきました.

  • 素朴に page.tsx だけを使った実装では, 再マウントでスクロール位置や内部状態がリセットされてしまうため注意
  • 一覧を layout.tsx で表示する方法はシンプルだが, ファイル構成から各ファイルの役割が読み取りづらいのと, layout.tsx の役割がやや多くなる
  • Parallel Routes は挙動に癖があるが, これを使うと各ファイルの役割を明示しつつ, 一覧の表示の役割も layout.tsx から分離できる
  • Intercepting Routes はハードナビゲーション時とソフトナビゲーション時で挙動を変えたいときに使いましょう