こんにちは!Dartの世界へようこそ。
FlutterやDartの開発において、「アプリの状態管理」は避けては通れない非常に重要なテーマですよね。画面が「読み込み中なのか」「成功してデータを表示しているのか」「エラーが発生しているのか」といった状態(State)を、いかに安全に、そしてシンプルに表現するか。
Dart 3で導入された「sealed class(封印されたクラス)」と「パターンマッチング(pattern matching)」の組み合わせは、この課題に対するDartとしての究極の回答です。
これらを組み合わせることで、私たちは「代数的データ型(ADT)」と呼ばれる非常に堅牢なデータ構造を構築できます。今回は、この強力な機能を、初心者の方や他言語から来られた方に向けて、仕組みから実践的なコード、そしてコンパイラが裏側でどう私たちを助けてくれているのかまで、優しく丁寧に解説していきます。
「ここをクリアすれば、Dartの基本とモダンな設計手法はバッチリマスターできますよ」
さあ、一緒に新しいDartの扉を開けてみましょう!
—
1. なぜ「sealed class」が必要なのか?
まずはイメージを共有しましょう。
あなたがネットショッピングのアプリを作っているとします。決済処理の状態は、以下の3つのいずれかになりますよね。
- 準備中 (Pending)
- 成功 (Success) — 決済IDなどのデータを持つ
- 失敗 (Failure) — エラーメッセージなどのデータを持つ
従来のDart(Dart 2以前)では、これらを表現するために「共通の親クラスを継承した3つの子クラス」を作っていました。しかし、これには大きな弱点がありました。
// 従来の書き方(Dart 2以前のイメージ)
abstract class PaymentStatus {}
class Pending extends PaymentStatus {}
class Success extends PaymentStatus { final String transactionId; Success(this.transactionId); }
class Failure extends PaymentStatus { final String message; Failure(this.message); }
この方法だと、プログラムの別の場所で、誰かが勝手に `class UnknownStatus extends PaymentStatus {}` のような新しい状態を作れてしまいます。また、`switch`文で状態を分岐させるときに、「すべての状態を漏れなく処理できているか」をコンパイラがチェックしてくれません。
結果として、処理し忘れた状態(バグの温床)が残り、実行時にアプリがクラッシュする原因になっていました。
sealed classは「完璧に閉じられた箱」
そこで登場したのが `sealed class` です。
`sealed`(シールド=封印された)という名前の通り、「このクラスを継承できるのは、同じファイル内に定義された子クラスだけ」という強力な制約をかけます。
[ 同一ファイル内 ]
+———————————–+
| sealed class | <--- 外部からはこれ以上増やせない!
+-----------------------------------+
| | |
[Pending] [Success] [Failure]
これにより、コンパイラは「`PaymentStatus` の具体的な種類は、絶対にこの3つ(`Pending`, `Success`, `Failure`)以外に存在しない」ということを100%確信できるようになります。この「確定された状態のグループ」のことを、専門用語で「代数的データ型(ADT)」と呼びます。
---
2. 実践!sealed classとパターンマッチングの基本形
それでは、具体的なコードを見ていきましょう。
Dart 3の `switch` は、従来の「文(Statement)」だけでなく、値を直接返す「式(Expression)」としても書けるようになり、パターンマッチングと合わさることで劇的に美しくなりました。
// 1. sealed classの定義(同一ファイルに書きます)
sealed class PaymentStatus {}
class PaymentPending extends PaymentStatus {}
class PaymentSuccess extends PaymentStatus {
final String transactionId;
PaymentSuccess(this.transactionId);
}
class PaymentFailure extends PaymentStatus {
final String errorMessage;
PaymentFailure(this.errorMessage);
}
// 2. 状態を判定してメッセージを返す関数(switch式を使用)
String getStatusMessage(PaymentStatus status) {
// switch式は、マッチした結果をそのまま return できます
return switch (status) {
PaymentPending() => ‘決済を処理しています。少々お待ちください…’,
PaymentSuccess(transactionId: id) => ‘決済が完了しました! ID: $id’,
PaymentFailure(errorMessage: error) => ‘決済に失敗しました。理由: $error’,
};
}
void main() {
final status1 = PaymentPending();
final status2 = PaymentSuccess(‘TXN-99999’);
final status3 = PaymentFailure(‘残高不足です。’);
print(getStatusMessage(status1)); // 出力: 決済を処理しています。少々お待ちください…
print(getStatusMessage(status2)); // 出力: 決済が完了しました! ID: TXN-99999
print(getStatusMessage(status3)); // 出力: 決済に失敗しました。理由: 残高不足です。
}
コードのここがポイント!
- `return switch (status) { … }`:
`switch`が式になり、結果を直接 `return` しています。コードがとてもスッキリしますね。
- `PaymentSuccess(transactionId: id)`:
これが「パターンマッチング(デストラクト/解体)」の魔法です。
「もし型が `PaymentSuccess` だったら」という型判定と同時に、そのクラスが持っている `transactionId` というプロパティを取り出して、変数 `id` に自動的に代入しています。わざわざ `as PaymentSuccess` とキャストして `status.transactionId` と書く必要はもうありません。
—
3. コンパイラの賢さを知る:網羅性(Exhaustiveness)チェック
`sealed class` と `switch` 式の組み合わせが「最強」と呼ばれる最大の理由は、Dartコンパイラによる「網羅性(もうらせい)チェック」にあります。
仮に、あなたが新しい状態 `PaymentRefunded`(返金済み)を追加したとしましょう。
// 新しい状態を追加
class PaymentRefunded extends PaymentStatus {}
この瞬間、`getStatusMessage` 関数はコンパイルエラー(赤線)になります。
The type ‘PaymentStatus’ is not exhaustively matched by the switch cases.
(PaymentStatus型は、switchのケースで網羅されていません。PaymentRefundedが不足しています)
これがどれほど素晴らしいことか、想像してみてください。
もしあなたがコードを修正していて、1つの状態を追加したとき、「修正が必要な場所(switch文など)」をコンパイラがすべてエラーとして教えてくれるのです。
開発者が「あ、ここの処理を追加し忘れてた!」と、アプリをリリースした後にユーザーからの報告で気づく……なんて悲劇は、この仕組みによって完全に未然に防がれます。
—
4. 応用編:値の取り出し(デストラクト)とガード節(when)
さらに一歩進んだ、実戦的なテクニックをご紹介します。
パターンマッチングには、`when` というキーワードを使って「さらに条件を絞り込む」機能(ガード節)があります。
例えば、エラーの中でも「特定の深刻なエラー」だけを特別扱いしたい場合は、次のように書けます。
String getDetailedMessage(PaymentStatus status) {
return switch (status) {
PaymentPending() => ‘処理中…’,
// ガード節(when)を使って、特定のIDの場合だけ特別扱いする
PaymentSuccess(transactionId: id) when id.startsWith(‘VIP’) => ‘VIP会員様の決済が完了しました! (ID: $id)’,
PaymentSuccess(transactionId: id) => ‘決済完了 (ID: $id)’,
// エラーメッセージの内容によって分岐
PaymentFailure(errorMessage: msg) when msg.contains(‘残高不足’) => ‘口座の残高をご確認ください。’,
PaymentFailure(errorMessage: msg) => ‘エラーが発生しました: $msg’,
};
}
このように、型チェックだけでなく「その中身(値)」に依存した複雑な条件分岐も、`switch` 式の中で美しく、宣言的に記述することができます。
—
5. 陥りやすい文法エラーと対策
ここで、開発中によくやってしまいがちな「罠」を2つ紹介しておきますね。
罠1:安易に `_`(デフォルトパターン)を使ってしまう
`switch` 式で「その他すべて」を意味する `_`(ワイルドカード)を使うことができますが、`sealed class` と組み合わせる時は極力使わないようにしましょう。
// ⚠️ やってしまいがちなアンチパターン
String getMessageBad(PaymentStatus status) {
return switch (status) {
PaymentSuccess() => ‘成功!’,
_ => ‘それ以外(準備中、または失敗)’, // 楽だけど…
};
}
これをしてしまうと、将来 `PaymentRefunded`(返金)などの新しい状態を追加したときに、網羅性チェックが働かなくなります。新しい状態がすべて自動的に `_` に吸い込まれてしまい、バグの原因になります。
面倒でも、すべての状態を明示的に書き並べるのが、Dartの強みを100%活かすコツです。
罠2:別のファイルで継承しようとする
`sealed class` は、同じファイル内でのみ継承が許されます。
- `lib/models/status.dart` 内で `sealed class PaymentStatus` を定義
- `lib/screens/payment_screen.dart` で `class CustomStatus extends PaymentStatus {}` を定義
これはコンパイルエラーになります。状態の定義は必ず1つのファイルにまとめて整理しておきましょう。
—
まとめ:コンパイラと手を取り合って堅牢なコードを書こう
お疲れ様でした!今回の内容を簡単におさらいしましょう。
1. `sealed class` は、状態の種類を「これだけ!」と厳密に制限(封印)する。
2. `switch` 式 を使うことで、コードがシンプルになり、結果をダイレクトに返せる。
3. パターンマッチング によって、型キャストなしで安全に内部のデータ(プロパティ)を取り出せる。
4. 網羅性チェック が、状態の処理漏れをコンパイル時に100%検知して防いでくれる。
この `sealed class` とパターンマッチングの組み合わせは、Flutterのアプリ開発(特にBlocやRiverpodなどの状態管理ライブラリとの併用)において、今や世界標準のベストプラクティスとなっています。
最初は少し難しく感じたかもしれませんが、一度コードを書いてコンパイラがエラーを教えてくれる安心感を体験すれば、もう以前の書き方には戻れなくなるはずです。
「ここをクリアすれば、Dartの基本はバッチリマスターできますよ。自信を持って、どんどんコードを書いてみてくださいね!」
あなたのDart/Flutter開発ライフが、より楽しく、より安全なものになることを応援しています!