こんにちは。コードレビューで「なぜそのコードでは不十分なのか」「コンパイラの気持ちになれ」と後輩を静かに威圧するテクニカルリードの私だ。
君たちは日々、FlutterでのUI構築や、Dart製バックエンドの非同期API連携で華麗なコードを書いていることだろう。だが、Dart 3で導入されたパターンマッチングと、型システムの最深部に巣食う `Never` 型の本質を理解し、「コンパイラにコードの矛盾を証明させているか?」
今回は、Dartの型システムにおける究極のボトム型である `Never` と、Dart 3の `switch` 式 / パターンマッチングを組み合わせた、「ランタイムエラーを静的に絶滅させる極限の設計パターン」を伝授する。
ネットの適当な入門記事にあるような「網羅性チェックに `default` を書いておけばいいや」という甘い考えは、今日この場で捨ててもらおう。
—
1. なぜ `default` や `_` による網羅性回避は「悪」なのか
Dart 3のパターンマッチングは非常に強力だ。代数データ型(ADT)的な sealed クラスを `switch` で受ける際、コンパイラはすべてのケースが網羅されているかを静的に検証してくれる。
しかし、実務の現場でこんなコードを見かける。
sealed class UIState {}
class Loading extends UIState {}
class Success extends UIState {}
class Error extends UIState {}
String getMessage(UIState state) {
return switch (state) {
Loading() => ‘読み込み中…’,
Success() => ‘成功しました’,
Error() => ‘エラーが発生しました’,
_ => ‘未知の状態’, // ← ここが諸悪の根源
};
}
一見、安全に見えるだろうか? テクニカルリードの視点から言えば、最悪のアンチパターンだ。
もし将来、機能追加で `Maintenance(メンテナンス中)` という新しいサブタイプが `UIState` に追加されたとする。その時、コンパイラは上記のコードに対して「警告もエラーも出さない」。なぜなら、`_`(ワイルドカード)がすべての未知のケースを闇に葬り去ってしまうからだ。
結果として、本番環境でメンテナンス画面に遷移した瞬間、ユーザーには「未知の状態」という不親切なテキストが表示され、開発者はバグに気づけない。これが「型安全の放棄」である。
—
2. `Never` 型と `switch` 式による「到達不能のコンパイル時保証」
ここで登場するのが `Never` 型だ。
`Never` は、「絶対に値が存在しない(正常に完了しない)」ことを表すボトム型である。関数が例外をスローし続ける場合や、無限ループに入る場合の戻り値として使われる。
Dart 3のパターンマッチングにおいて、`Never` をどう活用するか。
答えはシンプルだ。デフォルトケース(ワイルドカード)の代わりに `Never` を返す関数を配置し、コンパイラに「ここには絶対に到達しない」ことを証明させるのだ。
以下のプロダクションコードを見てほしい。フロントエンドの非同期API連携やコンポーネントの状態管理でそのまま使える、極めて堅牢な設計パターンだ。
// ==========================================
// 実務で使える堅牢な状態管理とAPI連携の設計
// ==========================================
sealed class ApiResult
const ApiResult();
}
class ApiLoading
const ApiLoading();
}
class ApiSuccess
final T data;
const ApiSuccess(this.data);
}
class ApiError
final String message;
final int statusCode;
const ApiError(this.message, this.statusCode);
}
// TODO: 将来ここに ApiTimeout
/// 到達不能コードであることをコンパイラに強制し、
/// 未処理の型が存在する場合はコンパイルエラーを起こすヘルパー関数
Never _handleUnexpectedState(ApiResult
/// UIコンポーネント層でのレンダリングメッセージを解決する関数
String resolveApiMessage
return switch (result) {
ApiLoading() => ‘データを取得しています…’,
ApiSuccess(data: final d) => ‘取得成功: $d’,
ApiError(message: final msg, statusCode: 503) => ‘サーバーメンテナンス中です ($msg)’,
ApiError(message: final msg) => ‘エラーが発生しました: $msg’,
// 【極意】ワイルドカードではなく、Neverを返す関数をアサインする
// これにより、サブタイプが増えた瞬間にコンパイルエラーが爆誕する
_ => _handleUnexpectedState(result),
};
}
void main() {
ApiResult
// 実行
print(resolveApiMessage(result));
// 出力: 取得成功: Dart 3 完全に理解した
}
このコードが優れている理由
1. コンパイル時の網羅性保証(Exhaustiveness Checking)
もし将来、`ApiResult` に新しいサブクラス(例: `ApiTimeout`)を追加し、`resolveApiMessage` でそのハンドリングを書き忘れた場合、Dartのコンパイラは即座にコンパイルエラーを吐く。
「`_ => _handleUnexpectedState(result)` の部分で、`ApiTimeout` 型が網羅されていません」と、IDEが赤波線で教えてくれるのだ。
2. `Never` 型の型推論の妙
`_handleUnexpectedState` の戻り値は `Never` であるため、`switch` 式の各アーム(分岐)の戻り値型(この場合は `String`)と矛盾しない。`Never` はあらゆる型のサブタイプであるというDartの型システムの仕様を完璧にハックしている。
3. 「実行時安全」と「静的安全」の二段構え
万が一、JS連携や動的な型すり替えなどで想定外のオブジェクトが流れ込んできた場合でも、`_handleUnexpectedState` 内の `StateError` が即座に発火し、不具合の隠蔽を防ぐ。
—
3. Dart VM とコンパイラの内部挙動:なぜこれが効率的なのか
「でも、わざわざ関数を挟むオーバーヘッドがあるのでは?」と思ったそこのあなた。Dart VMとAOTコンパイラ(dart2native)の挙動を舐めてもらっては困る。
AOTコンパイル時、コンパイラ(GenSnapshot)は `Never` を返すコードパスに対して、「この分岐は物理的に到達不能(Dead Code)」であると最適化フェーズで判定する。
そのため、不要なジャンプ命令やランタイムチェックはネイティブバイナリ生成時に削ぎ落とされるか、極限までインライン展開される。
つまり、人間側の保守性(型の網羅性による安全性)を極限まで高めながら、機械側の実行パフォーマンスは一切落とさない。これが、Dartの言語仕様の深淵を知るアーキテクトのコードだ。
—
4. まとめ:今日からのコードレビューの基準を変えろ
チームメンバーのコードレビューをする際、`switch` やパターンマッチングで `default => …` や `_ => …` を見かけたら、こう問い詰めてほしい。
> 「そのワイルドカード、本当に必要か? 新しい状態が追加されたとき、コンパイラに検知させる気はあるか?」
`Never` 型をデフォルトケースの代わりに据える技術は、単なるテクニックではない。「変更に強く、バグの入り込む隙間すらない堅牢なアーキテクトの思想」そのものだ。
明日からの君たちのコードから、安易な `_` が消え去り、コンパイラと対話する美しいコードで満たされることを期待している。