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

Dart 3の`sealed`クラスにおける網羅性チェックを「あえて」回避する設計の過ちと正解

コードレビューで以下のコードを見た瞬間、私は差戻し(Changes Requested)ボタンを押します。

// 危険なアンチパターン:網羅性チェックの回避
String getErrorMessage(NetworkState state) {
return switch (state) {
NetworkStateFailed(:final message) => ‘エラー: $message’,
NetworkStateTimeout() => ‘タイムアウトしました’,
_ => ‘未知のステータス’, // ← 罪深いワイルドカード
};
}

作者のエンジニアは「将来新しいステータスが増えた時の防衛的プログラミングです」「ビルドエラーを消すためにデフォルトケースを入れました」と釈明するかもしれません。しかし、これは防衛どころか将来の重大なサイレントバグをコードベースに仕込む自爆行為です。

Dart 3で導入された `sealed` クラスと パターンマッチング、そして強力な網羅性チェック(Exhaustiveness Checking)は、Dartの型システムが我々に与えてくれた最高峰の安全装置です。

本記事では、Dart CoreコミッターおよびVMアーキテクチャの視点から、なぜ switch 式で網羅性チェックをあえて回避することが罪悪なのか、Dart VMおよびAOTコンパイラレベルでの挙動、そして実務で求められる「真に堅牢な設計パターン」を徹底解説します。

—

1. Dart VMとAOTコンパイラが見ている「網羅性」の正体

なぜ `_` (ワイルドカード) による網羅性回避が危険なのか。それを理解するには、Dart 3の型システムとコンパイラ内部で何が起きているかを知る必要があります。

静的解析と Flow Analysis(制御フロー解析)

`sealed` 宣言されたクラス階層は、コンパイル時にサブタイプの完全な閉包(Closure)を形成します。Dart 3の `Analyzer` は、このサブタイプツリー(代数的データ型における Sum Type 相当)を走査し、`switch` 式の各パターンがすべてのリーフノード(末端クラス)をカバーしているかを論理的に証明します。

[NetworkState (sealed)]
/ | \
[Success] [Failed] [Timeout]
|
[UnauthorizedFailed] // 将来追加されたとする

すべてのリーフがカバーされている場合、Dartのコンパイラは「この `switch` 式は絶対に例外を投げず、全ルートで値を出力する」と静的に保証できます。

AOTコンパイラとジャンプテーブル最適化

Dart AOTコンパイラ(`dart2native` や Flutterの Release ビルド)は、完全網羅された `switch` 式を検知すると、実行時の型タグ(Class ID)に基づく極めて高速な直接ジャンプテーブル(または高度に最適化された条件分岐ツリー)を生成します。

しかし、ここに `_`(ワイルドカード)を挟むとどうなるか?

1. フォールバック分岐の強制生成: コンパイラは「未知の型が来る可能性がある」と判定せざるを得ず、不要なフォールバック命令と境界チェックを残します。
2. デッドコード解析の無効化: 本来ならコンパイルエラーで弾くべき「未ハンドルの型」が、静的解析の網をすり抜けてランタイムに到達します。
3. Tree Shaking への悪影響: 到達しないはずの処理が `_` の先に存在することで、不要なコードがビルド成果物に引きずり込まれる原因になります。

つまり、網羅性を回避することは「型システムの静的チェックを自ら破壊し、AOTの最適化機会を捨て、ランタイムエラーの危険を背負う」ことに他なりません。

—

2. なぜエンジニアは「デフォルトケース」を書いてしまうのか?

現場でデフォルトケース(`_` や `default:`)が書かれる理由は、主に以下の3点に集約されます。

1. 「今はまだ扱わない状態」がある(例:UIで特定のエラー以外は共通トーストを出したい)
2. 「将来ステータスが増えた時にアプリをクラッシュさせたくない」という誤った防衛本能
3. コンパイルエラーを手っ取り早く消したいという無頓着

しかし、ドメインモデルが拡張され、`NetworkState` に `NetworkStateUnauthorized`(認可エラー)が追加された場面を想像してください。

  • 網羅性を維持している場合:

コンパイラが `Missing case: NetworkStateUnauthorized` と警告(ビルドエラー)を出します。開発者はCI環境で即座に気づき、ログイン画面へのリダイレクト処理を実装できます。

  • `_` で回避している場合:

ビルドは成功します。認可エラーが発生したユーザーの画面には、静かに「未知のステータス」という無意味な文字列が表示され、バグ報告が届くまで誰も気づきません。

> 【テクニカルリードの鉄則】
> 「コンパイルエラーは最高のトモダチであり、ランタイムバグは最悪の敵である」
> ビルドを落として開発者に気づかせることこそが、最もコストの低いエラーハンドリングです。

—

3. 「特定パターンのみを扱いたい」場合の正しい設計パターン

「とは言っても、10個ある状態のうち2個だけ特別扱いして、残りは同じ処理にしたいんだ」というユースケースは確実に存在します。その場合、`_` でお茶を濁すのではなく、明示的かつ構造的な意図を持った設計を行う必要があります。

パターンA: サブグループ化(Sealed Hierarchy の再構築)

ドメインのモデル化自体を見直すアプローチです。状態の階層構造を正確に定義します。

sealed class NetworkState {}

// 成功系
final class NetworkStateSuccess extends NetworkState { … }

// エラー系を sealed で括る
sealed class NetworkStateFailure extends NetworkState {}
final class NetworkStateFailed extends NetworkStateFailure { … }
final class NetworkStateTimeout extends NetworkStateFailure { … }
final class NetworkStateUnauthorized extends NetworkStateFailure { … }

このように設計すれば、エラー系のみを対象とした switch で網羅性を保つことができます。

// NetworkStateFailure の範囲内で完全網羅される
String handleFailure(NetworkStateFailure failure) => switch (failure) {
NetworkStateFailed(:final message) => ‘エラー: $message’,
NetworkStateTimeout() => ‘タイムアウト’,
NetworkStateUnauthorized() => ‘再ログインが必要です’,
};

パターンB: 明示的な ガード節(If-Case)による部分抽出

`switch` 式で網羅性を破るのではなく、「特定の型にしか関心がない」ことを `if (state case …)` で明示します。

// switch 式ではなく if-case を使うことで、「部分一致」が意図的であることをコードで表明する
void handleSpecificError(NetworkState state) {
if (state case NetworkStateUnauthorized()) {
// 認可エラーのみをリダイレクト処理
redirectToLogin();
return;
}

// それ以外は一般的な処理
showGenericErrorToast();
}

—

4. プロダクション適用コード例:非同期API連携と状態ハンドリング

実務の現場でそのまま使える、堅牢で美しい `sealed` クラス設計と `switch` 式の完全網羅パターンを示します。

以下は、Web/MobileアプリのAPI連携において、非同期レスポンスを厳格にハンドリングするコード例です。

import ‘package:meta/meta.dart’;

// ============================================================================
// 1. ドメイン層:完全閉包されたレスポンス状態の定義
// ============================================================================
@immutable
sealed class AsyncResult {
const AsyncResult();
}

final class AsyncData extends AsyncResult {
final T data;
const AsyncData(this.data);
}

final class AsyncLoading extends AsyncResult {
final double? progress; // 0.0 ~ 1.0
const AsyncLoading({this.progress});
}

sealed class AsyncError extends AsyncResult {
final Object error;
final StackTrace stackTrace;
const AsyncError(this.error, this.stackTrace);
}

// エラー詳細のサブタイプ
final class NetworkError extends AsyncError {
final int statusCode;
const NetworkError(super.error, super.stackTrace, {required this.statusCode});
}

final class UnknownError extends AsyncError {
const UnknownError(super.error, super.stackTrace);
}

// ============================================================================
// 2. プレゼンテーション層 / コンポーネント設計:完全網羅 switch 式
// ============================================================================
class UiMapper {
/// すべての状態を100%厳格にハンドリングする(デフォルトケースなし)
static String mapToUiMessage(AsyncResult result) {
return switch (result) {
// レコードパターンとデストラクチャリングを活用
AsyncData(:final data) => ‘データ取得成功: $data’,

AsyncLoading(:final progress) => switch (progress) {
final p? => ‘読み込み中… ${(p 100).toInt()}%’,
null => ‘読み込み中…’,
},

NetworkError(:final statusCode) => switch (statusCode) {
401 || 403 => ‘アクセス権限がありません (Code: $statusCode)’,
404 => ‘リソースが見つかりません’,
_ => ‘ネットワークエラーが発生しました (Code: $statusCode)’,
// ※ int(プリミティブ型)に対する switch なので、ここでは _ が必要かつ正当
},

UnknownError(:final error) => ‘システムエラーが発生しました: $error’,
};
}

/// 【あえて部分抽出を行う場合】
/// _ を使う switch 式ではなく、if-case を用いて意図を明確化する
static bool shouldShowRetryButton(AsyncResult result) {
// ネットワークエラー、または未知のエラーの時だけリトライ可能とする
return result case NetworkError() || UnknownError();
}
}

// ============================================================================
// 3. 動作確認用のメイン関数
// ============================================================================
void main() {
final AsyncResult state1 = AsyncData(‘ユーザー情報’);
final AsyncResult state2 = NetworkError(
‘Unauthorized’,
StackTrace.current,
statusCode: 401,
);
final AsyncResult state3 = AsyncLoading(progress: 0.45);

print(UiMapper.mapToUiMessage(state1)); // データ取得成功: ユーザー情報
print(UiMapper.mapToUiMessage(state2)); // アクセス権限がありません (Code: 401)
print(UiMapper.mapToUiMessage(state3)); // 読み込み中… 45%

print(‘Retry allowed (state2): ${UiMapper.shouldShowRetryButton(state2)}’); // true
print(‘Retry allowed (state3): ${UiMapper.shouldShowRetryButton(state3)}’); // false
}

—

5. テクニカルリードのPRチェックリスト

チームのコード品質を極限まで高めるため、コードレビュー時には以下の基準を徹底してください。

1. `sealed` クラスに対する `switch` 式の中に `_` や `default` が存在しないか?

  • 存在する場合、即座に差し戻す。例外なく型を追加した際にコンパイルエラーを発生させるように指導する。

2. 「部分一致」を行いたい場合、`switch` 式ではなく `if (x case Y)` を使っているか?

  • 文脈として「網羅したいのか」「特定条件だけ判定したいのか」を構文レベルで使い分けさせる。

3. `enum` や `sealed` のサブタイプに対する `_` と、`int` や `String` などの無限の値をとる型に対する `_` を区別できているか?

  • 値の範囲が無限のプリミティブ型に対する `_` は必須だが、代数的データ型に対する `_` は怠慢である。

—

結論

Dart 3のパターンマッチングと `sealed` クラスは、単なるシンタックスシュガーではありません。「コードの変更に対して、コンパイラを最強の監視者にする」というアーキテクチャ上の思想そのものです。

switch 式における `_` による網羅性回避は、その監視者の目を自ら眩ます行為です。
型システムのパワーを最大限に引き出し、リファクタリングに絶対の自信を持てる堅牢な Dart コードを書き上げましょう。

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