【実務・中級編】Dart 3のsealed classとswitch式:網羅性チェックを活かした堅牢なドメインモデル設計 – Dart コア文法・オブジェクト指向・Null安全解析バイブル

Dart 3のsealed classとswitch式で構築する「壊れない」ドメインモデル:コンパイラを最強のレビューアにする方法

Dart 3以降の進化において、最も劇的なパラダイムシフトは「言語仕様レベルでの網羅性チェック」の導入です。

多くのエンジニアは `switch` を単なる `if-else` の置き換えと考えていますが、それは大きな誤解です。我々が書くコードは、コンパイル時にDart VMやAOTコンパイラが「この状態遷移は数学的に閉じているか?」を検証するための証明書でなければなりません。

本記事では、`sealed class` と `switch` 式を組み合わせ、「仕様変更をコンパイルエラーとして検出する」極限まで堅牢な設計手法を伝授します。

—

なぜ、従来のクラス設計では「バグ」が潜むのか

従来の `enum` や継承によるポリモーフィズムでは、状態の分岐を忘れた際に実行時までバグが露見しませんでした。例えば、APIレスポンスのステータス管理で `if (result is Success)` と書いた場合、後から `Loading` や `Expired` が追加されても、コンパイラは静観し、プロダクション環境で `null` アクセスや未定義挙動を引き起こします。

我々が目指すべきは、「状態が増えたら、対応する箇所のコンパイルが通らなくなる」という強制力です。

—

網羅性を担保するドメインモデルの設計

まずは、UIの状態管理を例に、`sealed class` を用いた堅牢なドメインモデルを定義します。

/// UIの状態を表現するsealed class
/// コンパイル時にこのライブラリ内でしか継承できないことが保証される
sealed class UiState {
const UiState();
}

class Loading extends UiState {
const Loading();
}

class Data extends UiState {
final T value;
const Data(this.value);
}

class Error extends UiState {
final Object error;
final StackTrace stackTrace;
const Error(this.error, this.stackTrace);
}

この定義の肝は `sealed` 修飾子です。これにより、コンパイラは「`UiState` のサブクラスはこれら3つ以外に存在しない」という閉じた世界を認識します。

—

switch式:コンパイラによる「思考」の強制

次に、この状態をUIに反映させるコードを見てみましょう。ここで `switch` 式(statementではなくexpression)を使うのがポイントです。

Widget buildContent(UiState state) {
// switch式を使用。これら全てを網羅しないとコンパイルエラーになる
return switch (state) {
Loading() => const CircularProgressIndicator(),
Data(value: final v) => Text(‘Result: $v’),
Error(error: final e) => Text(‘Error occurred: $e’),
// ここに新しい状態(例: Expired())を追加すると、
// 即座にコンパイルエラーとなり、開発者に修正を促す
};
}

なぜこれが「美しい」のか

1. 網羅性の強制: 仮に `Expired` クラスを追加した場合、この `switch` 式は即座にエラーを吐きます。修正漏れという概念が物理的に消滅します。
2. パターンマッチングの威力: `Data(value: final v)` のように、プロパティを直接抽出(デストラクト)できるため、冗長なキャストや一時変数が不要です。
3. 高効率な実行: AOTコンパイル時、`sealed class` の `switch` は最適化されたジャンプテーブルに変換されます。`if-else` の連鎖によるO(n)の探索コストを回避し、定数時間での分岐が可能です。

—

実務で差がつく「パターンマッチング」の応用テクニック

実務ではAPIレスポンスのハンドリングで、より高度なパターンマッチングが威力を発揮します。

// APIレスポンスを階層化する例
sealed class ApiResponse {
const ApiResponse();
}

class Success(final int code, final Map data) extends ApiResponse;
class Failure(final int code, final String message) extends ApiResponse;

// 呼び出し側でのハンドリング
String handleResponse(ApiResponse response) => switch (response) {
Success(code: 200, data: final d) => ‘Success: $d’,
Success(code: final c) => ‘Redirect or other success: $c’,
Failure(code: 401) => ‘Unauthorized. Please login.’,
Failure(code: final c) => ‘Error code $c’,
};

ここで注目すべきは、ガード条件(`code: 200`)と型安全な変数バインド(`final d`)の混在です。これにより、単なる型による分岐を超えた、ビジネスロジックに直結した制御フローが実現できます。

—

伝説のチーフアーキテクトからの助言

最後に、パフォーマンスと保守性の観点から一つだけ警告しておきます。

`sealed class` は強力ですが、ライブラリを跨いだ継承はできません。これは「閉じたドメイン」を保つための仕様です。もしコンポーネントライブラリとして提供するコードで、外部から状態を追加させたい場合は、`sealed` ではなく `interface` や抽象クラスを使用する必要があります。

しかし、アプリケーション層のドメインモデルにおいては、「外部からの拡張を許さない(=不整合を起こさせない)」ことこそが最大の防御です。

Dartのコンパイラは、あなたよりも遥かに優秀なコードレビューアです。`sealed` と `switch` を使いこなすことで、あなたの書くコードは「動く」だけでなく「壊れない」という極めて高い価値を持つようになります。

さあ、今すぐ `if-else` のスパゲッティを捨て、静的型付けの恩恵を最大限に引き出す設計へシフトしてください。それが、技術的負債をゼロにする唯一の近道です。

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