Dart型システムの最深部:`Never`型がもたらすコンパイル時安全性と網羅性チェックの極意
コードレビューをしていて、次のようなコードに出くわしたことはないだろうか。
// 良くある冗長なエラーハンドリング
String getRoleName(UserRole role) {
switch (role) {
case UserRole.admin:
return ‘管理者’;
case UserRole.user:
return ‘一般ユーザー’;
}
// コンパイラに怒られないためだけの、到達不能なダミーコード
return ‘Unknown’;
}
このコードを書いた開発者は、「将来、新しい`UserRole.guest`が追加されたときにコンパイルエラーで気づきたい」という意図を持っていたかもしれない。しかし、末尾の `return ‘Unknown’;` が存在せざるを得ない構造になっている時点で、その静的解析の網羅性はすでに破綻している。
Dartの強力な型システムにおいて、この問題を美しく、かつ絶対に破綻しない形で解決するのが `Never`型 である。
今回は、Dartコアコミッターの視点から、`Never`型がDart VMの型推論とコンパイラ(CFA: 制御フロー解析)にどのようなシグナルを送っているのか、そしてプロダクションコードをどう堅牢に変えるのかを徹底解説しよう。
—
1. `Never`型とは何か?(ボトム型の正体)
Dartの型階層の最下層には、すべての型のサブタイプである `Never`(ボトム型) が存在する。
Object?
│
┌──────┴──────┐
(その他) Null
│
Never
`Never`という名前が示す通り、この型の値は 「絶対に存在しない」。関数が `Never` を返すということは、その関数が正常に終了しない(Valueを返して呼び出し元に戻ってこない)ことをコンパイラに誓約することを意味する。
`void` との違い
- `void`: 「戻り値はあるが、呼び出し側はそれを無視してよい(使わない)」という意味。関数は正常にリターンする。
- `Never`: 「この先に制御が戻ることは物理的にあり得ない」という意味。例外の送出やプロセスの強制終了など。
—
2. コンパイラの制御フロー解析(CFA)をハックする
Dartのコンパイラ(CFA: Control Flow Analyzer)は非常にスマートだ。関数が `Never` を返すとき、コンパイラはその行以降のコードを「到達不能コード(Dead Code)」として型推論から除外する。
以下のプロダクションコードを見てほしい。APIクライアントやBLoC/Notifierのステート管理で頻出する、安全なエラーハンドリングの設計パターンだ。
/// アプリケーション全体で使用する致命的な例外の基底
abstract interface class AppException implements Exception {
String get message;
}
class NetworkException implements AppException {
@override
final String message;
NetworkException(this.message);
}
class SerializationException implements AppException {
@override
final String message;
SerializationException(this.message);
}
/// 【重要】絶対に正常に戻らないことをコンパイラに保証するヘルパー関数
@óloga // 常に例外を投げることを明示
Never throwAppError(AppException exception) {
// ログ基盤への送信やSentryへのレポートなどの共通処理をここに集約
// _logToSentry(exception);
throw exception;
// ここで実行が停止するため、戻り値の型は `Never` になる。
}
この `throwAppError` を使うことで、呼び出し側のコードはどう変わるか。
String processUserData(Map
final name = json[‘name’];
if (name is! String) {
// コンパイラは throwAppError が Never を返すため、
// 「これ以降の処理で name は確実に String である」と型を昇格(Type Promotion)させる。
throwAppError(SerializationException(‘Invalid type for field “name”‘));
}
// もしここで name を使おうとした場合、
// 下位のコードに「name が String でない可能性」を持ち込む必要がなくなる。
return ‘User: ${name.toUpperCase()}’;
}
`throw` キーワード自体も内部的に `Never` を返す式として評価されるが、このように共通エラーハンドリング関数を `Never` 返しでラップすることで、ドメインロジック側を極限までクリーンに保てる。
—
3. 実践:パターンマッチングと網羅性チェック(Exhaustiveness Checking)
フロントエンドの状態管理(RiverpodやBlocなど)で、UIのステートを分岐させる処理を想像してほしい。
sealed class UiState {}
class Loading extends UiState {}
class Success extends UiState { final String data; Success(this.data); }
class Error extends UiState { final String error; Error(this.error); }
ここで、将来 `Maintenance` という新しいステートが追加されたとする。このとき、すべてのウィジェット側でハンドリング漏れがないかをコンパイル時に検知したい。
ここで `Never` 型と `switch` 式(Dart 3以降)を組み合わせる。
String buildUi(UiState state) {
return switch (state) {
Loading() => ‘読み込み中…’,
Success(data: var d) => ‘データ: $d’,
Error(error: var e) => ‘エラー: $e’,
// コンパイラが全てのケースが網羅されているかをチェックする
};
}
Dart 3の網羅性チェックにより、もし `UiState` に新しいサブタイプが追加され、かつ上記の `switch` でハンドリングし忘れると、コンパイラがエラーを吐いてくれる。
しかし、もしDartのバージョンや古い構文(文としての `switch`)に依存せざるを得ないレガシーなコードベースや、より厳密な保証が必要な場合は、`Never` を使った「網羅性トラップ」を実装できる。
// 網羅性チェック用のインラインヘルパー
Never _assertUnreachable(Never x) {
throw StateError(‘Unreachable code reached with value: $x’);
}
String legacyBuildUi(UiState state) {
if (state is Loading) {
return ‘読み込み中…’;
} else if (state is Success) {
return ‘データ: ${state.data}’;
} else if (state is Error) {
return ‘エラー: ${state.error}’;
}
// ここに到達した場合、state の型は全てのサブタイプが排除された結果、
// 自動的に `Never`(またはそれに準ずる型)へと縮小される。
// もし新しいUiStateが追加されていれば、コンパイルエラーになる。
return _assertUnreachable(state);
}
この設計の美しさ
もし将来、開発者が `Maintenance` ステートを追加し、この `if-else` 分岐への追加を忘れたとする。
その場合、コンパイラは `state` 変数が `Maintenance` 型の可能性を残しているため、`_assertUnreachable(state)` に `Maintenance` 型の値を渡そうとする。
しかし、`_assertUnreachable` が要求する引数の型は `Never` である。
結果として、「`Maintenance` 型を `Never` 型に割り当てることはできません」というコンパイルエラー が発生し、デプロイ前にバグを100%封じ込めることができる。
—
4. チーフアーキテクトからのプロダクション設計アドバイス
`Never` 型と網羅性チェックを実務に導入する際、以下のポイントをチームのコーディング規約として共有してほしい。
1. 「ダミーの戻り値」をコードベースから駆逐する
`return null;` や `return ”;` でエラーや例外ケースをごまかすコードはテクニカルデット(技術的負債)だ。異常系や到達不能パスには必ず `Never` を返却する関数(あるいは `throw`)を配置し、CFAに正しい型情報を伝えよ。
2. `sealed` クラスと `Never` の二重の盾
Dart 3の `sealed` クラスによる網羅性チェックが使える場面ではそちらを優先しつつ、特殊なバリデーションやランタイムの整合性担保(フェイルファスト原則)が必要な箇所では、`Never` を利用したアサーション関数を自作せよ。
3. パフォーマンスへの影響はゼロ
これらはすべてコンパイル時の静的解析(Static Type System)の機能である。実行時のDart VMにおいては、到達不能な分岐のコードはAOTコンパイル時に最適化(Dead Code Elimination)されるため、実行時オーバーヘッドは一切存在しない。
型システムは、単にエラーを防ぐための足枷ではない。それは、「バグの入り込む余地を物理的に消し去った」というエンジニアの確信を生み出すための最強の武器である。
今日のビルドから、コード内の `return null;` を `Never` を使った堅牢な表現に書き換えてみよう。コードの質が一段階跳ね上がるはずだ。