こんにちは!FlutterやDartの開発現場で、日々コードを書いていらっしゃることと思います。
Dartを学び始めると、`int`や`String`、そして最近のDart 3で強力になった「パターンマッチング」など、ワクワクする機能がたくさん登場しますよね。ここをしっかりクリアすれば、Dartの基本はバッチリマスターできますよ!
今回は、Dartの型システムの中でも、特に深淵で美しい`Never`型と、Dart 3のパターンマッチングを組み合わせた「到達不能コードのコンパイル時保証」という、ちょっと知的で実用的なテクニックを優しく解説していきますね。
—
1. まずはおさらい:Dart 3のパターンマッチングと `switch` 式
Dart 3から、`switch`は単なる「文(Statement)」ではなく、値を返す「式(Expression)」として書けるようになりました。これが本当に便利なんです。
例えば、ユーザーの権限(Role)に応じてメッセージを切り替えるコードを考えてみましょう。
enum Role { admin, editor, viewer }
String getWelcomeMessage(Role role) {
return switch (role) {
Role.admin => ‘ようこそ、管理者さま!’,
Role.editor => ‘編集者モードです。’,
Role.viewer => ‘閲覧専用モードです。’,
};
}
このコード、とてもスッキリしていて綺麗ですよね。でも、ここでちょっとした未来のバグの芽について考えてみましょう。
もし、将来的に新しい権限 `Role.guest` が追加されたらどうなるでしょうか?
実は、Dartの優秀なコンパイラは、すべての網羅性(Exhaustiveness)をチェックしてくれるため、「おいおい、`Role.guest` のケースが抜けてるよ!」とコンパイルエラーを出して教えてくれます。
—
2. 「網羅できない」複雑な条件と、デフォルトケースの罠
しかし、すべてのケースが単純なEnumのように列挙できるとは限りません。
例えば、次のような「JSONなどの動的なデータ」や「複雑な型階層」を扱う場合です。
String handleValue(Object value) {
return switch (value) {
int i => ‘整数です: $i’,
String s => ‘文字列です: $s’,
// 他にもいろんな型が来るかもしれない…
_ => ‘その他の何かです’,
};
}
ここで登場するのが、おなじみのデフォルトケース(ワイルドカード) `_ => …` ですよね。
「とりあえず、想定外のものが来たらここに流しておこう」という安全弁として非常に便利です。
しかし!ここに大きな落とし穴があります。
もしあなたが「将来的に新しい型(例えば `bool`)が渡されたとき、デフォルトケースにスルーさせずに、必ずコンパイルエラーで気づきたい」としたらどうでしょう?
`_ =>` を使ってしまうと、新しい型が追加されてもコンパイラは何も文句を言わず、すべて「その他の何か」として処理されてしまいます。これでは、潜在的なバグを見逃してしまう原因になりますよね。
—
3. 救世主 `Never` 型の登場:到達不能をコンパイル時に保証する
ここで、今回の主役である `Never` 型 の出番です。
`Never` 型とは、Dartの型システムにおいて「絶対に値が存在しない(正常に完了することがない)」ことを表す底(ボトム)型です。代表的なものとして、常に例外を投げる関数(`throw` を呼ぶ関数など)の戻り値などに使われます。
この `Never` 型とパターンマッチングを組み合わせることで、「ここには絶対に到達してはならない。もし到達するような変更(型の追加など)があったら、コンパイルエラーを発生させなさい」という強力な制約をコンパイラに課すことができます。
実際のコードを見てみましょう。
sealed class NetworkResult {}
class Success extends NetworkResult {
final String data;
Success(this.data);
}
class Error extends NetworkResult {
final String message;
Error(this.message);
}
// 使い方
String processResult(NetworkResult result) {
return switch (result) {
Success(data: var d) => ‘成功: $d’,
Error(message: var m) => ‘エラー: $m’,
// 万が一、NetworkResultに新しいサブクラスが追加された場合…
// ここで Never型 を使ってコンパイルエラーを誘発する!
_ => throw StateError(‘到達不能なコードです: ${result.runtimeType}’),
};
}
あれ? `throw` を使っていますね。これの何が `Never` 型と関係あるのでしょうか?
実は、Dartでは `throw` 式自体の型は `Never` です。
そして、Dartのコンパイラは非常に賢いため、以下のような推論を行います。
1. `switch` 式のすべての分岐は、最終的に同じ戻り値の型(この場合は `String`)を返す必要がある。
2. `_ => throw StateError(…)` の右側にある `throw` は `Never` 型を返す。
3. `Never` 型は「あらゆる型のサブタイプ」であるため、`String` を期待するこの `switch` 式の型チェックをパスする。
さらに一歩進んだ、安全性の極み
もし、将来的に `Loading` という新しい状態クラスが `NetworkResult` に追加されたとします。
class Loading extends NetworkResult {}
もし、あなたが `processResult` 関数内の `switch` に `Loading` のケースを書き忘れたとしましょう。
もし通常の `_ =>` を使っていると、`Loading` は `_`(デフォルトケース)に吸い込まれ、実行時までバグに気づきません。
しかし、ここで 「デフォルトケースの型を明示的に `Never` にバインドする」 というテクニックを使うと、コンパイルの挙動が変わります。
String processResultStrict(NetworkResult result) {
return switch (result) {
Success(data: var d) => ‘成功: $d’,
Error(message: var m) => ‘エラー: $m’,
// パターンの網羅性を完璧に保証するイディオム
// (NetworkResult に新しい型が追加されると、ここがコンパイルエラーになる)
var unknown =>
throw ArgumentError(‘未対応の型です: ${unknown.runtimeType}’),
};
}
おっと、これだけだと先ほどと似ていますね。
より厳格に「このデフォルトケース自体が、型システム上不要(=すべてのケースが網羅されているべき)」であることをコンパイラに強制するには、次のように書きます。
String processResultExhaustive(NetworkResult result) {
return switch (result) {
Success(data: var d) => ‘成功: $d’,
Error(message: var m) => ‘エラー: $m’,
// sealedクラスなどで全ケースを網羅している場合、
// ここに到達することはないはず。
};
}
もし `sealed` クラスを使っていれば、デフォルトケース `_` を書かなくても、Dartのコンパイラが「網羅されていないよ!」と教えてくれます。
しかし、「万が一、型システムの隙間をすり抜けて未知のオブジェクトが流れ込んできたとき、それをコンパイル時ではなくとも確実に検知したい、あるいはコードの意図として『ここは絶対にあり得ない』と明示したい」という場面で、`Never` を返り値に持つヘルパー関数を使うアプローチがプロの間で好まれます。
// 到達不能であることをコンパイラと人間(チームメンバー)に伝える専用関数
Never handleUnreachable(Object? invalidValue) {
throw StateError(‘予期せぬ値が渡されました: $invalidValue (${invalidValue?.runtimeType})’);
}
String processWithNever(NetworkResult result) {
return switch (result) {
Success(data: var d) => ‘成功: $d’,
Error(message: var m) => ‘エラー: $m’,
// ここで `Never` を返す関数を呼び出す
// (※もし型が完全に網羅されていれば、この行自体がコンパイルエラーまたは到達不能として扱われます)
var unknown => handleUnreachable(unknown),
};
}
—
まとめ:なぜこのテクニックを使うのか?
1. 意図の明確化: 「このケースは設計上絶対に存在しない」という開発者の意図をコード上で明確に表現できます。
2. 保守性の向上: クラス階層(`sealed` クラスなど)を変更した際に、見落としがちな分岐の追加漏れを型システムがサポートしてくれます。
3. 安全なフォールバック: 万が一の実行時エラー時にも、単なる `null` や適当な文字列を返すのではなく、明確な型情報(`Never` を伴う例外)を持って安全にクラッシュさせ、バグの早期発見につなげられます。
Dartの型システムは、私たちが書くコードの背後で、常に厳密に世界を監視してくれています。この `Never` 型の特性をマスターすれば、あなたの書くDart/Flutterコードは、より堅牢で、バグ知らずの美しいものに生まれ変わりますよ。
ここをクリアできれば、Dartの型安全性への理解はもう中級者以上です!
ぜひ、今日の開発から試してみてくださいね。それでは、また次回の深掘り記事でお会いしましょう!