FlutterやWebフロントエンド開発の現場で、こんなコードレビューをしたことはないだろうか。
// 良くあるアンチパターン
class UIState {
final bool isLoading;
final String? data;
final String? error;
UIState({this.isLoading = false, this.data, this.error});
}
「おい、これでは『ロード中でかつエラーも発生している状態』という、物理的にあり得ない矛盾したインスタンスが型レベルで生成できてしまうぞ。なぜ `sealed class` で状態を排他制御しなかったんだ?」
WebエンジニアがAPI連携や非同期コンポーネントの状態管理を実装する際、この「不可能な状態を型で表現しない」という設計思想が欠落しているケースが後を絶たない。フラグの数だけバグの温床が増える地獄だ。
Dart 3の導入により、我々は言語仕様レベルで 代数データ型(ADT: Algebraic Data Types) を完全な形で扱えるようになった。`sealed class` と `switch` 式(パターンマッチング)のコンビネーションは、単なるシンタックスシュガーではない。コンパイラの静的解析能力を極限まで引き出し、「網羅性(Exhaustiveness)の保証」によってバグの芽をコンパイルエラーとして物理的に刈り取るための最強の武器だ。
今回は、Dartコアを知り尽くしたアーキテクトの視点から、プロダクションコードで即座に使える圧倒的に堅牢な状態管理設計を伝授する。
—
1. なぜ `sealed class` なのか?(JVMやTSとの違い)
TypeScriptのUnion型やRustの `enum` に慣れ親しんだ開発者であれば、ADTの威力は説明不要だろう。Dartにおける `sealed class` は、同一ライブラリ内でのみサブクラス化を許可する修飾子だ。
AOT(Ahead-Of-Time)コンパイラおよびJITのDart VMは、`sealed` が付与されたクラス階層を解析する際、サブクラスが有限であることを確定できる。これにより、`switch` 式を用いたパターンマッチングにおいて、コンパイラが「すべてのパターンが網羅されているか」を完全かつ高速に検証できるのだ。
もし将来、新しい状態が追加されたとき、過去に書いたすべての `switch` 式でハンドリングを忘れていれば、容赦なくコンパイルエラーが発生する。 人的ミスが入り込む余地を言語仕様が許さない。これこそが堅牢なアーキテクチャの要諦である。
—
2. プロダクション品質のAPI状態管理ADT実装
では、実務の非同期API連携でそのまま使えるプロダクションコードを見ていこう。
ローディング、成功、リトライ可能なエラー、致命的なエラーの4つの状態を厳密に定義する。
import ‘package:meta/meta.dart’;
/// 1. sealed classによる代数データ型の定義
/// 同一ライブラリ(ファイル)内でのみ拡張可能。外部からの勝手なサブクラス化を禁止する。
@immutable
sealed class ApiState
const ApiState();
}
// 状態A: 初期・アイドル状態
final class ApiIdle
const ApiIdle();
}
// 状態B: 通信中
final class ApiLoading
const ApiLoading();
}
// 状態C: 成功(不変データを保持)
final class ApiSuccess
final T data;
const ApiSuccess(this.data);
}
// 状態D: 失敗(エラー情報とリトライ可能性を保持)
final class ApiError
final String message;
final bool isRetryable;
const ApiError({required this.message, required this.isRetryable});
}
コミッターの着眼点:パフォーマンスとメモリ効率
`const` コンストラクタを徹底している点に注目してほしい。 Dart VMのヒープアロケーションにおいて、不変(Immutable)なオブジェクトに対する `const` の活用は、インスタンスの使い回し(Canonicalization)を促進し、GC(ガベージコレクション)の負荷を劇的に軽減する。特に頻繁に再描画が発生するFlutterのウィジェットツリーや、高頻度で状態が変化するWebフロントエンドのストアにおいて、これはパフォーマンス上の決定的な差となる。
—
3. switch式による網羅的パターンマッチングとUIレンダリング
次に、定義したADTを `switch` 式(StatementではなくExpression)を用いて評価する。
ここで重要なのは、if-elseのネストや、網羅性のないswitch文を一切排除することだ。
/// 2. switch式による安全なUIコンポーネントの構築
String renderUI
// Dart 3の switch は「式」なので、値を直接returnできる
return switch (state) {
// パターンマッチングと同時に型プロモーション(Type Promotion)が走るため、
// キャストを書くことなく安全にプロパティへアクセスできる。
ApiIdle() => ‘データ待ち受け中です…’,
ApiLoading() => ‘
ApiSuccess(data: final d) => ‘表示データ: ${onDataRender(d)}’,
ApiError(message: final msg, isRetryable: true) =>
‘エラーが発生しました: $msg ‘,
ApiError(message: final msg, isRetryable: false) =>
‘回復不能なエラーです: $msg’,
};
// 【重要】もしここで、将来的に `ApiMaintenance` という新しい状態を追加し忘れた場合、
// コンパイラが「The type ‘ApiState
// エラーを吐き出し、ビルドを強制停止させる。
}
void main() {
// 実行例のシミュレーション
ApiState
print(renderUI(currentState, (data) => data.toUpperCase()));
// 出力: 表示データ: DART 3 ARCHITECTURE
}
なぜこの書き方が優れているのか?
1. 型プロモーションの恩恵: `ApiSuccess(data: final d)` のように書くことで、パターンマッチと同時に `d` が元の型 `T` として安全にスコープ内にバインドされる。冗長な `(state as ApiSuccess).data` のような危険なダウンキャストは一生書く必要がない。
2. ガード節(Guards)の活用: Dartのパターンでは `when` 句を使って条件をさらに絞り込むことも可能だ。例えば `ApiError(message: final m) when m.contains(‘401’)` のように、値の構造だけでなく条件に基づいた分岐もエレガントに記述できる。
—
4. アーキテクチャ設計におけるアンチパターンと最適解
コードレビューにおいて、以下のようなコードを見かけたら即座にリファクタリングを指示してほしい。
❌ 避けるべきアンチパターン
- 生のエラーハンドリング (`try-catch` の野放し):
非同期処理の境界で `try-catch` をし、UI層まで `Exception` オブジェクトをそのまま伝播させる設計は、UI層が例外の型やメッセージに依存する結合度の高いスパゲッティコードを生む。API層の境界で必ず `ApiState` のようなADTへカプセル化すべきである。
- 網羅性のない `default` や `else` の乱用:
`switch` 文の最後に `default:` を書く癖がついているエンジニアは要注意だ。`default` を書いた瞬間、新しい状態を追加したときにコンパイラが警告を出してくれなくなり、ランタイムエラーの温床になる。原則として `sealed class` に対する `switch` 式で `default` は使用してはならない。
—
5. 結びにかえて:言語の重みを背負ったコードを書け
Dartは、単なる「Flutterのための言語」ではない。厳格な静的型付けと、モダンな関数型言語のエッセンスが融合した、極めて洗練されたモダン言語である。
今回紹介した `sealed class` とパターンマッチングのコンビネーションは、単にコードを綺麗にするためではない。「実行時エラーをコンパイル時エラーに置き換える」 という、ソフトウェア工学における最も高潔な目的を達成するための手段だ。
君たちが書くコードの1行が、アプリケーション全体の堅牢性を決める。
明日からのコードレビューでは、フラグ管理のクラスを撲滅し、美しくエレガントなADT設計をチームにインストールしてほしい。