こんにちは!DartとFlutterの世界へようこそ。
アプリ開発をしていて、「通信中のローディング表示」「データの描画」「エラー時の再試行画面」といったUIの状態管理で頭を悩ませたことはありませんか?
かつてのプログラミングでは、`data == null` かどうかでローディングを判定したり、フラグの組み合わせ(`isLoading && !hasError` のようなもの)で画面を切り替えたりして、予期せぬバグ(画面が真っ白になる、ローディングが消えないなど)を生みがちでした。
しかし、現代のDart(Dart 3以降)には、「Sound Null Safety(健全なNull安全)」と「sealed class(シール型クラス)」という強力な武器が揃っています。この2つを組み合わせると、「あり得ない状態を型レベルで排除し、コンパイラに守られながら100%安全にUIを描画する」という理想的な設計が驚くほどシンプルに書けるようになります。
今回は、Dartのコンパイラが裏でどのように型を保証しているのかという本質にも触れながら、現場で一生モノの武器になる「状態管理の決定版パターン」を優しくステップバイステップでマスターしていきましょう!ここをクリアすれば、Dartの型システムはバッチリ使いこなせますよ。
—
1. なぜ従来の「Nullを使った状態管理」は危険だったのか?
まずは、よくあるアンチパターンから見てみましょう。多くの初学者が一度は通る道です。
// ⚠️ 避けたほうがよい昔ながらの書き方
class UserState {
final User? user; // nullならロード中?それとも未ログイン?
final bool isLoading; // ロード中フラグ
final String? error; // エラーメッセージ(nullなら正常?)
UserState({this.user, this.isLoading = false, this.error});
}
この設計には、DartのNull安全をもってしても防ぎきれない「状態の矛盾(不正な組み合わせ)」が発生する余地があります。
- `isLoading == true` なのに `error` にも文字列が入っている(ロード中なの?エラーなの?)
- `isLoading == false` なのに `user == null` かつ `error == null`(何を表示すればいいの?)
このように、Null許容型(`User?` や `String?`)を安易に並べてしまうと、「存在しないはずの異常な状態」が作れてしまうのです。
—
2. 救世主『sealed class』の登場!
そこで登場するのが `sealed class`(シール型クラス) です。
日本語では「封印されたクラス」とも呼ばれます。
sealed class のイメージ図
`sealed class` は、そのクラスを継承できる子クラスの範囲を「同じファイル内」に完全に限定(封印)します。
┌──────────────────────────────────────────────────────────┐
│ sealed class │
│ 【 AsyncState 】 │
│ │
│ コンパイラ「このファイル内に存在する子クラスが全てだと保証する!」 │
└───────────────┬───────────────────────────┬──────────────┘
│ │
┌──────────────▼──────────────┐ ┌─────────▼─────────────────┐
│ AsyncLoading(ロード中) │ │ AsyncError(失敗) │
│ データは存在しない! │ │ error: String (非Null) │
└─────────────────────────────┘ └───────────────────────────┘
│
┌──────────────▼──────────────┐
│ AsyncSuccess(成功) │
│ data: T (非Null) │
└─────────────────────────────┘
コンパイラは「`AsyncState` には Loading, Success, Error の3種類しか絶対に存在しない」という事実を100%把握できます。
—
3. 実践:sealed class と Null安全で作る完璧な状態モデル
それでは、実際に現場で使える汎用的な非同期UI状態を定義してみましょう。ジェネリクス(`
// 1. sealed をつけて状態の親玉を定義
sealed class AsyncState
const AsyncState();
}
// 2. 「初期状態 / ロード中」: 余計なプロパティを持たせない
class AsyncLoading
const AsyncLoading();
}
// 3. 「成功」: 必ずデータが存在するので【非Null型 T】を持つ
class AsyncSuccess
final T data; // ← 絶対に null にならない!
const AsyncSuccess(this.data);
}
// 4. 「エラー」: 必ずエラー理由が存在するので【非Null型 String】を持つ
class AsyncError
final String message; // ← 絶対に null にならない!
const AsyncError(this.message);
}
ここが美しいポイント!
- `AsyncSuccess` のときだけデータを取り出せます(ロード中に誤って `data` に触る心配がゼロ)。
- 各クラスのフィールドはすべて 非Null型(Non-Nullable) です。余計な `?` がコードから完全に消え去りました。
—
4. パターンマッチング(switch式)でUIを描画する
Dart 3から導入された `switch` 式 と組み合わせると、この設計の真価が発揮されます。
// ユーザー情報のモッククラス
class User {
final String name;
final int age;
const User(this.name, this.age);
}
// 状態に応じてUIの文字列を返す関数(FlutterのWidget構築をイメージしてください)
String renderUI(AsyncState
return switch (state) {
AsyncLoading() => ‘⏳ データを読み込み中…’,
// パターンマッチングで内部の data をスマートに取り出す(Type Promotion)
AsyncSuccess(:final data) => ‘👤 ようこそ、${data.name}さん!(年齢: ${data.age}歳)’,
AsyncError(:final message) => ‘❌ エラーが発生しました: $message’,
};
}
void main() {
// 1. ロード中
AsyncState
print(renderUI(state)); // 実行結果: ⏳ データを読み込み中…
// 2. 成功時
state = const AsyncSuccess(User(‘アリス’, 25));
print(renderUI(state)); // 実行結果: 👤 ようこそ、アリスさん!(年齢: 25歳)
// 3. エラー時
state = const AsyncError(‘ネットワークに接続できません’);
print(renderUI(state)); // 実行結果: ❌ エラーが発生しました: ネットワークに接続できません
}
コンパイラが発動する「網羅性チェック(Exhaustiveness Checking)」
もし、あなたが `AsyncError` のハンドリングをうっかり書き忘れたとしましょう。
String renderUI(AsyncState
return switch (state) {
AsyncLoading() => ‘読み込み中…’,
AsyncSuccess(:final data) => ‘こんにちは、${data.name}さん’,
// ⚠️ AsyncError を書き忘れた!
};
}
すると、コンパイラは即座に次のようなエラーを出してビルドを止めてくれます。
> `The type ‘AsyncState
> (訳:AsyncStateの全パターンが網羅されていません。AsyncErrorのケースが足りませんよ!)
`default:` や `else` で適当にお茶を濁す必要はありません。すべての状態を漏れなく処理していることを、Dartが機械的に保証してくれるのです。
—
5. 初学者がハマりやすい注意点と処方箋
Q1. 従来の `abstract class` とは何が違うの?
A: `abstract class` は別のファイルからでも自由に `implements`(拡張)できてしまいます。そのため、コンパイラから見ると「世界中のどこかで新しい子クラスが作られているかもしれない」となり、`switch` の網羅性を静的に判定できません(結果として `default` 節が必須になります)。
`sealed` をつけることで、「子クラスはこのファイルにあるものだけ!」とコンパイラに宣言できるのが決定的な違いです。
Q2. フィールドの取り出し記法(`:final 変数名`)がよくわかりません
A: これは Dart 3 の オブジェクトパターン(Object Pattern) という構文です。
// 従来の書き方
AsyncSuccess(data: final u) => ‘こんにちは、${u.name}さん’
// プロパティ名と同じ変数名で受け取る場合の省略記法
AsyncSuccess(:final data) => ‘こんにちは、${data.name}さん’
`AsyncSuccess` の持つプロパティ `data` を自動で分解(デストラクト)して、そのままローカル変数 `data` として使える便利なシンタックスシュガーです。
—
6. まとめ:コンパイラを味方につけて堅牢なDartコードを書こう!
今回学んだ「sealed class × Null安全」のアプローチを使うことで得られるメリットをおさらいしましょう。
- 状態の矛盾を撲滅: 「ロード中かつエラー」のような不正な状態が型定義の時点で存在し得なくなる。
- Null安全の最大活用: 不要な `?`(Null許容型)がコードから消え、実行時クラッシュがなくなる。
- 追加・変更に強い: 将来新しい状態(例: `AsyncEmpty` = データが0件)を追加したとき、修正すべき画面の `switch` すべてをコンパイラが赤線で教えてくれる。
このパターンは、Flutterにおける Riverpod や BLoC といった状態管理ライブラリと組み合わせる際にも、デファクトスタンダードとして使われています。
Dartの型システムは、私たちエンジニアを縛るルールではなく、開発を楽にしてくれる頼もしいパートナーです。ぜひご自身のプロジェクトでも、この `sealed class` を活用した状態管理を取り入れてみてくださいね!