こんにちは!Dartの世界へようこそ。
Dart 3がリリースされてから、私たちの開発スタイルは大きく進化しました。特に`sealed class`(封印クラス)と`switch`式(Switch Expressions)の組み合わせは、型安全で洗練されたコードを書くための強力な武器になりましたよね。
`sealed` を使うと、コンパイラが「すべてのパターンを網羅しているか?」を完璧にチェックしてくれる網羅性チェック(Exhaustiveness Checking)が働きます。
しかし、実際の現場で開発を進めていると、ふとこんな悩みにぶつかることはありませんか?
> 「新しく状態を追加したとき、コンパイルエラーにして絶対に気づけるようにすべき?」
> 「それとも、アプリがクラッシュしないように `_`(デフォルトケース)を書いておいた方が安全なのかな……?」
この記事では、 Dartの内部メカニズムを踏まえつつ、「あえて網羅性チェックを回避(デフォルトケースを記述)する設計」の是非と、その適切な使い分けについて、一緒に紐解いていきましょう!
ここをクリアすれば、Dartの型システムを自在にコントロールできるようになり、基本はバッチリマスターできますよ!
—
1. そもそも「網羅性チェック」とは?
まずは基本のおさらいです。Dart 3の `sealed class` は、「そのクラスを継承できるファイルを同一ライブラリ(ファイル)内に限定する」という特徴を持っています。
これにより、Dartコンパイラ(内部的には CFE: Common Front End と呼ばれる解析器)は、「そのクラスのサブタイプ(子クラス)が世界にいくつ存在するか」をコンパイル時に100%把握できるようになります。
// 1. sealed class で状態を定義
sealed class NetworkState {}
class Success extends NetworkState {
final String data;
Success(this.data);
}
class Failure extends NetworkState {
final String error;
Failure(this.error);
}
class Loading extends NetworkState {}
この `NetworkState` を `switch` 式で受けるとき、Dartは全パターンが書かれているかを厳密にチェックします。
// 網羅されているので OK!
String getMessage(NetworkState state) {
return switch (state) {
Success(data: final d) => ‘成功: $d’,
Failure(error: final e) => ‘エラー: $e’,
Loading() => ‘読み込み中…’,
};
}
もしここで `Loading()` の行を忘れると、コンパイラは即座に `The type ‘NetworkState’ is not exhaustively matched…` というエラーを出して教えてくれます。実行する前にミスに気づける、最高の仕組みですね!
—
2. あえて回避する? デフォルトケース(`_`)の誘惑
ここで本題です。`switch` 式には、どんなパターンにもマッチするワイルドカード `_`(あるいは `default`)を書くことができます。
String getMessageWithDefault(NetworkState state) {
return switch (state) {
Success(data: final d) => ‘成功: $d’,
// Failure と Loading を「まとめて」デフォルトで受ける
_ => ‘その他の状態です’,
};
}
これを書いた瞬間、 Dartコンパイラは「うん、ワイルドカードがあるから100%網羅されているね!」と判断し、網羅性チェックの警告やエラーを一切出さなくなります。
一見すると「コードが短くなってラッキー!」思えるかもしれませんが、ここには大きな設計上のトレードオフ(メリットとデメリット)が隠されています。
—
3. メンタルモデルで理解する「デフォルトケース」の影響
コンパイラと実行時の動きを図解で見てみましょう。
【 デフォルトケースを書かない場合(厳密モード) 】
Subtypes: [Success, Failure, Loading]
Switch : [Success, Failure, Loading] ──► 100% Match (安全)
▼ 将来、Maintenance(メンテナンス中)を追加すると…
Subtypes: [Success, Failure, Loading, Maintenance]
Switch : [Success, Failure, Loading] ──► コンパイルエラー!(開発者がすぐ気付く)
【 あえてデフォルトケースを書く場合(回避モード) 】
Subtypes: [Success, Failure, Loading]
Switch : [Success, _ ] ──► 100% Match (コンパイラは満足)
▼ 将来、Maintenance(メンテナンス中)を追加すると…
Subtypes: [Success, Failure, Loading, Maintenance]
Switch : [Success, _ ] ──► コンパイルは成功してしまう!
└─► 実行時に Maintenance が `_` に吸い込まれる(バグの温床)
デフォルトケースを書くということは、コンパイラが提供してくれる「未来の変更に対する安全網」を自ら解除する行為なのです。
—
4. 「デフォルト回避」のメリット・デメリット比較
では、どのような視点で設計を選択すべきなのでしょうか?
❌ デメリット:ドメインロジックの「抜け漏れ」が発生する
最も恐ろしいのは、仕様変更で新しい状態が増えたときに、処理の追加を忘れてしまうことです。
例えば、決済処理に `Pending`(保留中)という状態が追加されたとします。
厳密な `switch` で書いていれば、ビルドした瞬間にエラーが出るため、エンジニアは全員「修正しなきゃ!」と気づけます。
しかし `_` が書かれていると、アプリはそのままビルドできてしまい、ユーザーの画面には「未知のエラーが発生しました」という無機質なデフォルトUIが表示され続けてしまいます。
⭕️ メリット:システムの「堅牢性(レジリエンス)」が高まる
一方で、あえて `_` を使うのが正しい設計となる場面もあります。
それは「外部からの変更に強くしたい境界(バウンダリ)」です。
例えば、Web APIのレスポンスを受け取る箇所や、プラグインからのイベント通知などです。
サーバー側で新しいステータスが追加されたとき、スマホアプリ側が未対応だからといって画面全体がクラッシュ(例外終了)するのは困りますよね。そうした「未知の入力に対してフォールバック(予備動作)を行いたい場所」では、`_` を置く設計が極めて有効になります。
—
5. 実践! 状況に応じた最適な設計パターン
ここからは、実務で使える2つの黄金パターンコードをご紹介します。状況に応じてこれらを使い分けられるようになれば、Dartマスターへの道は開かれたも同然です!
パターンA:コア領域(ドメイン層・UI状態)
👉 【原則】デフォルトケースは絶対に書かない!
アプリの中核となるビジネスロジックや画面の状態管理では、コンパイラを100%信頼しましょう。
// アプリ内部のコアな状態定義
sealed class AuthState {}
class Unauthenticated extends AuthState {}
class Authenticating extends AuthState {}
class Authenticated extends AuthState { final String userId; Authenticated(this.userId); }
// 将来、TwoFactorRequired (2段階認証が必要) が追加されるかも?
class LoginScreenViewModel {
String get buttonText(AuthState state) {
// 💡 あえて `_` を書かないことで、TwoFactorRequired が増えたときに
// 即座にコンパイルエラーを発生させ、修正漏れを防ぐ!
return switch (state) {
Unauthenticated() => ‘ログイン’,
Authenticating() => ‘処理中…’,
Authenticated() => ‘マイページへ’,
};
}
}
パターンB:境界領域(API連携・外部入力)
👉 【原則】あえて `_` を使い、ログを吐きながら安全にフォールバックする!
外部システムと接する場所では、未知のデータが来てもアプリを落とさない設計にします。
// サーバーから送られてくるユーザーの権限状態
sealed class ServerUserRole {}
class AdminRole extends ServerUserRole {}
class MemberRole extends ServerUserRole {}
class GuestRole extends ServerUserRole {}
// サーバー側で「ModeratorRole」が先行して追加された場合…
class UserRoleMapper {
static String toLabel(ServerUserRole role) {
return switch (role) {
AdminRole() => ‘管理者’,
MemberRole() => ‘一般会員’,
GuestRole() => ‘ゲスト’,
// 💡 あえてデフォルトケースを用意し、未知のロールを安全に処理する
_ => () {
// 開発環境やログ収集サービスに通知して気づけるようにしておく
assert(false, ‘未定義のRoleを検知しました: ${role.runtimeType}’);
return ‘不明な権限’;
}(),
};
}
}
※補足: `switch` 式の中で複数の処理を行いたい場合は、上記のように即時実行無名関数 `() { … }()` を使うと綺麗に書けますよ!
—
まとめ:設計の「意志」を持って `switch` を使い分けよう
Dartの `switch` 式と `sealed class` は、単なる条件分岐の構文ではなく、「システムの将来の変更に対してどう備えるか」という設計思想そのものです。
最後に、判断に迷ったときのチェックリストをまとめました。
1. アプリ内部で閉じているロジックか?
👉 はい:`_` は使わない。(コンパイラに厳密にチェックさせる)
2. 新状態の追加=必ず新機能のUI/処理の実装が必要か?
👉 はい:`_` は使わない。(修正漏れをエラーで防ぐ)
3. 外部連携や、未知の状態でも最低限アプリを動かし続けたい場所か?
👉 はい:`_` を使って安全にフォールバックする。
「エラーを出してくれるコンパイラは、一番親切な仲間」です。基本的にはコンパイラに頼りつつ、必要な境界でだけ「あえて」回避する。この感覚が掴めれば、あなたのDartコードは劇的に保守しやすくなりますよ!
ぜひ、今日の開発から意識してみてくださいね。応援しています!