【実務・中級編】Dartのsealed classと網羅性チェック:switch式で「未知のサブクラス」をどう扱うか – Dart コア文法・オブジェクト指向・Null安全解析バイブル

コードレビューをしていて、最も頭を抱えたくなる瞬間の一つが、「将来の拡張を見据えていない不完全な条件分岐」に遭遇したときだ。特にAPIのステータス管理やUIのコンポーネント状態分岐において、新しい状態を追加した瞬間にアプリ全体が沈没するようなコードは、プロの仕事とは言えない。

Dart 3で導入された `sealed` 修飾子とパターンマッチング(`switch` 式)は、この問題に終止符を打つための強力な武器だ。しかし、現場の設計でよく議論になるのが「網羅性チェック(Exhaustiveness Checking)の恩恵を受けつつ、予期せぬ未知のサブクラス(あるいは将来の拡張)に対してどう安全に備えるか」という点である。

今回は、Dartコンパイラが裏側でどう型を評価しているかのメカニズムに踏み込みつつ、保守性と堅牢性を極限まで高めたプロダクションコードの設計パターンを授けよう。

—

1. なぜ従来の `enum` や `abstract class` では破綻するのか?

Webフロントエンド(FlutterやDart Web)のAPI連携において、非同期処理の状態(Loading, Success, Error)を表現する際、かつては `enum` が多用されていた。

// 悪夢の始まり:ただのenum
enum Status { initial, loading, success, error }

このアプローチの致命的な欠陥は、データ(ペイロード)を持てないことだ。`success` ならパースされたモデルを、`error` なら例外オブジェクトを内包させたい場合、enumではお手上げになり、結果として「nullableなフィールドの山」を持った貧血症のクラスが量産される。

これを解決するために `abstract class` を使っても、Dartコンパイラは「他にサブクラスが存在しないこと」をコンパイル時に保証できないため、`switch` 文には必ず `default:` や `else` が必要になる。結果として、新しい状態を追加してもコンパイラが警告してくれず、デバッグ時にランタイムエラーを踏むという最悪のバグを生む温床となる。

—

2. Dart 3の `sealed class` と網羅性チェックの真価

Dartの `sealed class` は、同一ライブラリ内でのみサブクラス化を許可する。これにより、CFA(Control Flow Analysis:制御フロー解析)とコンパイラは「このクラスのサブクラスは、このファイル群にあるこれらだけだ」と完全に把握できる。

ここで、コンパイル時にすべてのサブクラスが `switch` で処理されているかを検証する 網羅性チェック(Exhaustiveness Checking) が発動する。

sealed class UiState {}
class Initial extends UiState {}
class Loading extends UiState {}
class Success extends UiState implements DataPayload {
final T data;
Success(this.data);
}
class Error extends UiState {
final Object error;
Error(this.error);
}

この `UiState` を `switch` 式(Expression) で処理する場合、コンパイラは `Initial`, `Loading`, `Success`, `Error` の4つすべてがハンドリングされているかを厳密にチェックする。もし `Error` の分岐を書き忘れたら、コンパイルエラーになる。これが、変更に強い堅牢なコードの土台だ。

—

3. 現場のジレンマ:「未知のサブクラス」をどう扱うか?

さて、ここからが本題だ。
例えば、あなたが開発しているデザインシステムや基盤ライブラリを、他のチームがサードパーティとして拡張するケース、あるいは外部APIから、想定外のステータス文字列がJSONパース時に突撃してくるケースを想像してほしい。

「コンパイル時の網羅性チェックの恩恵(=新しい状態を追加した時に漏れなくコンパイルエラーで気づけること)は絶対に維持したい。しかし、万が一、未定義のサブクラスが流れ込んできたときに、アプリ全体が白画面(Crash)になるのは避けたい」

このトレードオフを完璧に調停する設計パターンが、「フォールバック・キャッチオール戦略」だ。

痛みを伴わない、しかし安全なプロダクションコード例

以下のコードを見てほしい。これは、APIから受け取る非同期データの状態を表現しつつ、将来の拡張や想定外のケースに備えた、実務でそのまま使える完成形のコンポーネントレンダラーだ。

import ‘package:flutter/material.dart’;

/// 1. sealed classによるドメインモデルの定義
sealed class Resource {
const Resource();
}

class Idle extends Resource {
const Idle();
}

class Loading extends Resource {
const Loading();
}

class Success extends Resource {
final T data;
const Success(this.data);
}

class Failure extends Resource {
final Object error;
const Failure(this.error);
}

// 【重要】将来の拡張や外部要因による未知のケースを許容するための「エスケープハッチ」
// 同一ライブラリ内(あるいは拡張を許す設計)であれば、サードパーティが勝手に生やす可能性がある。
class UnknownState extends Resource {
final String rawType;
const UnknownState(this.rawType);
}

/// 2. UIレンダリングコンポーネント
class ResourceView extends StatelessWidget {
final Resource resource;
final Widget Function(T data) onSuccess;

const ResourceView({
super.key,
required this.resource,
required this.onSuccess,
});

@override
Widget build(BuildContext context) {
// switch「式」による網羅的かつ高速な評価
return switch (resource) {
Idle() => const SizedBox.shrink(),
Loading() => const Center(child: CircularProgressIndicator()),
Success(:final data) => onSuccess(data),
Failure(:final error) => Center(child: Text(‘Error: $error’)),

// === ここがキモ ===
// すべての既知のサブクラスを網羅しつつ、
// 予期せぬサブクラス(UnknownStateや、将来追加されてハンドリング漏れしたもの)を安全に捕捉する
// ※注: Dartの網羅性チェックにおいて、BaseクラスやWildcard( _ )を置くと
// 厳密な網羅性エラーが消えてしまうため、設計上の工夫が必要。
// 下記の解説を参照。
};
}
}

アーキテクトの鋭いツッコミ:コンパイラ警告とどう共存するか?

鋭い読者ならこう気付くはずだ。「あれ? `switch (resource)` の最後に `Resource() => …` や `_ => …` を書いたら、コンパイラが『すでに網羅されています』という警告を出すか、あるいは未知のケースを吸い取ってしまって新しい状態を追加した時のコンパイルエラー(=検知アラート)が機能しなくなるのでは?」と。

その通り。Dartのコンパイラは非常に優秀であるゆえに、網羅された `switch` の後にワイルドカード(`_`)や親クラスを置くと、「到達不能コード(Dead Code)」あるいは「網羅性チェックの無効化」を引き起こす。

では、どうするか?

答えは、「未知のサブクラスを型システムの内側ではなく、`Object` やパターンのガード、あるいは明示的なフォールバック用サブクラスとして設計し、開発時と実行時で挙動をコントロールする」ことだ。

もしあなたが「厳格な網羅性」を維持したいなら、`_` や `Resource` 自体を網羅的switchの最後に入れてはならない。代わりに、ライブラリの境界(APIクライアントやJSONパーサー)の時点で、未知の値はすべて `UnknownState` にマッピングする。

// JSONからDomain Modelへのマッピング層
Resource parseUserResponse(Map json) {
return switch (json[‘status’]) {
‘idle’ => const Idle(),
‘loading’ => const Loading(),
‘success’ => Success(User.fromJson(json[‘data’])),
‘error’ => Failure(json[‘message’]),

// APIの仕様変更で知らないステータスが来ても、アプリがクラッシュしない
final unknown => UnknownState(unknown.toString()),
};
}

この設計であれば、UI側の `switch` 式では `UnknownState` を明示的にケースとして追加せざるを得なくなるため、「新しいAPIステータスが増えたときに、UI側のハンドリング漏れを100%コンパイル時に検知できる」という最強のメリットを維持できる。

// UI側でのスイッチ
return switch (resource) {
Idle() => const SizedBox.shrink(),
Loading() => const CircularProgressIndicator(),
Success(:final data) => Text(data.name),
Failure(:final error) => Text(‘Error’),

// 未知のステータスが追加されたら、ここに書かないとコンパイルが通らない!
// だから「未知のケース」の存在をコードレビューで絶対に見落とさない。
UnknownState(:final rawType) => Text(‘Unsupported state: $rawType’),
};

—

4. パフォーマンスとVMの最適化:裏側で何が起きているか?

最後に、このパターンがDart VMやAOTコンパイラ(特にFlutterのモバイル向けリリースビルド)において、いかに効率的であるかを解説しておこう。

1. ジャンプテーブル(Jump Table)またはインラインキャッシュの最適化:
`sealed class` による型階層が明確であるため、Dart VMのJIT / AOTコンパイラは、`switch` 式の分岐を仮想メソッドテーブル(vtable)のルックアップではなく、より高速な型のタグ比較、あるいは効率的なインラインキャッシュにコンパイルできる。余計な `is` チェックの連続によるパフォーマンス劣化が発生しない。
2. オブジェクトのアロケーション削減(`const` コンストラクタ):
上記のコード例を見てほしい。すべてのサブクラスに `const` コンストラクタを付与している。ステートレスな状態(`Idle`, `Loading`)は、アプリ全体でただ一つのインスタンスを使い回すことができ(Canonicalized)、GC(ガベージコレクション)の負荷を実質ゼロに抑えられる。

—

チーフアーキテクトからの提言

コードの美しさは、単に「行数が短い」ことではない。
「将来、誰かがコードを改変したときに、間違った変更ができない(=コンパイラが優しく、かつ厳しく教えてくれる)構造になっているか」、これこそがプロダクションコードの美しさの本質だ。

`sealed class` とパターンマッチングをただの「便利なシンタックスシュガー」として使うな。型システムの網羅性をハックし、変化に強く、かつ未知の脅威(APIの変更や拡張)にしなやかに耐える防壁として設計に組み込め。

明日のコードレビューでは、チームメンバーの書いた `switch` 文の `default:` をすべて剥がし、この「意図された網羅性とエスケープハッチの分離」が実践されているか確認してほしい。それだけで、君のプロジェクトの品質は一気に次のステージへ引き上げられるはずだ。

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