Dart 3パターンマッチングとSealed Classで焼き尽くす、脆弱なステートマシンのバグ
コードレビューをしていて、最も頭痛がする瞬間の一つがこれだ。
`isLoading`、`hasError`、`isSuccess`――。
これら独立したボールのようなフラグが無数に存在し、コンポーネントの内部で複雑に絡み合っている。「通信中かつエラー状態」という、物理法則を無視した不可能な状態(Impossible State)が、野放しに生成されるフロントエンドコード。そして、それを場当たり的な `if-else` のパッチワークで鎮火しようとしている現場。
いい加減にその泥舟から降りよう。
Dart 3で導入された `sealed class` と網羅的(Exhaustive)なパターンマッチングは、単なるシンタックスシュガーではない。「不正な状態を型レベルでコンパイル不可能にする」ための、コンパイラ要塞の築城術なのだ。
今回は、非同期API連携や複雑なUIコンポーネント設計の現場で、バグの入り込む隙を完全に遮断する「クリーンな有限ステートマシン(FSM)」の設計論を、実務直結のコードと共に授けよう。
—
なぜ従来のフラグ管理は破綻するのか?
TypeScriptや従来のDartでやりがちなアンチパターンを見てほしい。
// 【悪夢のアンチパターン】
class NetworkState {
bool isLoading = false;
bool isSuccess = false;
String? data;
String? errorMessage;
}
このアプローチの何がクソなのか?
組合せ爆発により、「データが存在するのに `isLoading` が `true` である」といった、ビジネスロジック的にあり得ない状態を表現できてしまう。テストを書こうにも、すべてのフラグの順列組合せをテストせねばならず、メンテンス性はゼロに等しい。
真のステートマシンとは、「現在どの状態にあり、その状態特有のペイロード(データ)は何であるか」が排他的に定義されているものである。
これをDart 3の `sealed` 修飾子と `switch` 式で完全に表現する。
—
実装:API連携を司る堅牢なステートマシン
今回は、実務で最も頻出する「非同期データフェッチ(Idle -> Loading -> Success / Error)」を題材にする。
まずは、状態の定義だ。各状態は `sealed class` のサブクラスとして定義され、必要なデータ(ペイロード)をその内部にカプセル化する。
import ‘package:meta/meta.dart’;
/// 1. 型安全なステートの定義 (Sealed Hierarchy)
sealed class FetchState
const FetchState();
}
class Idle
const Idle();
}
class Loading
const Loading();
}
class Success
final T data;
const Success(this.data);
}
class Error
final Object error;
final StackTrace stackTrace;
const Error(this.error, this.stackTrace);
}
なぜこれが強力なのか?
`sealed class` は、同一ファイル内でのみサブクラス化が許可される。これにより、Dart VM(およびAOTコンパイラ)は「この階層に存在するすべてのサブクラスはこれら4つで全てである」と完全に把握できる。
—
状態遷移ロジック(FSMコントローラー)の構築
次に、この状態を安全に遷移させるステートマシン(Notifier / ViewModel)を実装する。
ここでDart 3のパターンマッチングが真価を発揮する。
/// 2. 有限ステートマシンを内包するコントローラー
class UserProfileViewModel {
FetchState
// 外部への公開はイミュータブルな状態のみ
FetchState
/// アクション:ユーザーデータの取得を試みる
Future
// 状態遷移のバリデーション(FSMのガード条件)
// 例: ローディング中に再度fetchが走るのを防ぐ
switch (_state) {
case Loading():
// 既にロード中なら多重リクエストを弾く
return;
case Idle() || Success() || Error():
// 遷移可能な状態
break;
}
_state = const Loading();
_notifyListeners();
try {
// ネットワーク層の模擬
final userData = await _apiCall(userId);
// 成功状態へ遷移
_state = Success(userData);
} catch (e, st) {
// 失敗状態へ遷移
_state = Error(e, st);
} finally {
_notifyListeners();
}
}
void _notifyListeners() {
// UIやリスナーへの通知処理
print(‘Current State: [32m${_state.runtimeType}[0m’);
}
Future
await Future.delayed(const Duration(seconds: 1500));
if (id == ‘bad_id’) throw Exception(‘User not found’);
return ‘Alice (ID: $id)’;
}
}
—
UI / プレゼンテーション層:網羅的 `switch` Expression による安全網
コンポーネントの描画ロジックで `if-else` や `null` チェックを書いていないか?
Dart 3の `switch` 式(Expression)を使えば、「未処理の状態が存在する場合、コンパイルエラーになる」。新しい状態(例: `Offline`)を追加した瞬間、対応漏れのコードが即座にビルドエラーとして検出される。
/// 3. UIコンポーネントでの宣言的レンダリング
String renderUi
// Dart 3 の Switch Expression (網羅的チェック)
return switch (state) {
Idle() => ‘ボタンを押してデータを取得してください。’,
Loading() => ‘読み込み中…’,
Success(:final data) => ‘ようこそ、データ: $data’, // パターンマッチングによるプロパティのバインド
Error(:final error) => ‘エラーが発生しました: $error’,
};
}
このコードの美しさは、`Success(:final data)` の部分にある。
`Success` クラスのインスタンスから、ボイラープレートなキャストや `state.data` のような冗長なアクセスを必要とせず、パターンマッチングと同時にローカル変数へ安全にバインドしている。
—
伝説のアーキテクトからの警鐘:パフォーマンスとVMの最適化
ここで、チーフアーキテクトとしてパフォーマンスに関する極めて重要な知見を共有しておこう。
1. アロケーションコストの極小化
`const` コンストラクタを各状態クラスに付与していることに気づいただろうか?
`Idle` や `Loading` のようなペイロードを持たない状態は、すべて `const` インスタンスとして使い回すべきだ。不要なオブジェクトアロケーションを避けることで、FlutterのUIスレッドにおけるGC(ガベージコレクション)の負荷をゼロに近づけられる。
2. パターンマッチングのコンパイル時最適化
Dart VM(JIT/AOT)は、`sealed class` に対する `switch` 式を、効率的なジャンプテーブルやインラインキャッシュにコンパイルする。
InstanceOfの連鎖(`if (state is Success)`)を書いた場合、実行時に動的な型チェックのコストが発生するが、Dart 3の `switch` は型階層の閉包性が保証されているため、最適化が極めて効きやすい。
—
まとめ
バグの温床となる「散らばったフラグ」を捨て、`sealed class` とパターンマッチングによる「状態の型安全な閉包」を手に入れろ。
1. 状態は `sealed class` で網羅的に定義する
2. 遷移ガードは `switch` で明示的に制御する
3. UIやビジネスロジックの分岐は `switch` 式でコンパイラの網羅性チェックを強制する
このパターンを一度プロジェクトに導入すれば、二度と「なぜか画面が固まる」「不正なデータが表示される」という悪夢にうなされることはなくなるはずだ。
妥協のないコードを書け。それがプロフェッショナルだ。