【入門編】Dartのswitch式における網羅性チェックを「あえて」回避する設計の是非 – Dart コア文法・オブジェクト指向・Null安全解析バイブル

こんにちは!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コードは劇的に保守しやすくなりますよ!

ぜひ、今日の開発から意識してみてくださいね。応援しています!

タイトルとURLをコピーしました