要約
freezedのUnionは非常に便利です。コンパイラによる網羅性チェックが、状態管理を劇的に安全にしてくれます。 全人類使いましょう。
はじめに
APIからのレスポンスやユーザーのアクションなど、アプリの状態は刻一刻と変化します。if文やswitch文のネストで状態を管理し、nullチェックに疲弊していませんか?
この記事では、Flutter/Dartライブラリのfreezedが提供するUnionという機能がいかに強力で、安全かつ宣言的な状態管理を実現するのかを解説します。
対象読者
- Flutter/Dartで開発している人
- より安全で堅牢な状態管理の方法を探している人
- freezedの便利な使い方を知りたい人
freezedのUnionとは
freezedのUnionは、一言で言えば 「動的な値を持てるenum」 のようなものです。複数の状態を一つの型としてまとめ、それぞれの状態が固有のデータを持つことができます。
本質はDartのsealed classです。sealed classは、外部のファイルで継承や実装ができないという制約を持つabstract classです。
この制約のおかげで、コンパイラが事前にすべての実装を知ることができるため、まるでenumのようにパターンマッチングを行えます。
ドキュメントの後半に記述されているので、私はなかなか気づけませんでした。
freezed | Dart package#union-types
freezedとUnionの基本
freezedは、イミュータブル(不変)なクラスの定義を簡略化してくれるコードジェネレーターです。イミュータブルはモデル定義において、バグを減らすとても重要な性質です。値が不変であることが保証されているため、バグの追跡が容易になります。
Unionを使うには、名前付きfactoryコンストラクタを複数定義します。
最近私が実装した例ですが、ダウンロードの状態を追跡したいとしましょう。以下のように記述できます。
import 'package:freezed_annotation/freezed_annotation.dart';
part 'download_progress.freezed.dart';
part 'download_progress.g.dart';
@freezed
sealed class DownloadProgress with _$DownloadProgress {
const factory DownloadProgress.copying() = CopyingDownloadProgress;
const factory DownloadProgress.zipping() = ZippingDownloadProgress;
const factory DownloadProgress.completed({required String downloadUrl}) =
CompletedDownloadProgress;
factory DownloadProgress.fromJson(Map<String, Object?> json) =>
_$DownloadProgressFromJson(json);
}
-
CopyingDownloadProgress
状態やZippingDownloadProgress
状態は値を持たない -
CompletedDownloadProgress
状態はdownloadUrl
を持つ
このように、同じDownloadProgress型でありながら、状態によって異なる値を保持できるのがUnionの強みです。
Unionを使わない場合の問題点
もしUnionを使わない場合は以下のようになるかもしれません。
enum DownloadProgressType {
copying,
zipping,
completed,
}
class DownloadProgress {
final DownloadProgressType type;
final String? downloadUrl; // completed状態でのみ必要
const DownloadProgress({
required this.type,
this.downloadUrl,
});
}
この実装では以下の問題があります:
-
downloadUrl
がnullableになり、使用する際にnullチェックが必要 -
type
がcompleted
でない場合でもdownloadUrl
にアクセスできてしまう - 新しい状態を追加する際に、関連するプロパティの管理が煩雑
Unionを使うことで、これらの問題がすべて解決されます。どうでしょうか、Unionの良さが見えてきましたか?
パターンマッチングによる安全なハンドリング
Unionの真価はパターンマッチングと組み合わせることで発揮されます。Dart 3以降のモダンなswitch
式で、強力なパターンマッチングが可能になります。 Unionのおかげで事前にすべての型がわかるため、すべての状態に対する処理を網羅しない限りコンパイルエラーになります。 これこそが最大のメリットです。
実際の使用例
先ほど定義したDownloadProgress
を使って、実際にパターンマッチングを行ってみましょう:
// switch式を用いる例
final progress = DownloadProgress.completed(downloadUrl: 'https://example.com/file.zip');
final message = switch (progress) {
CopyingDownloadProgress() => 'ファイルをコピー中...',
ZippingDownloadProgress() => 'ZIPファイルを作成中...',
CompletedDownloadProgress(:final downloadUrl) => 'ダウンロード完了: $downloadUrl',
};
print(message); // 出力: ダウンロード完了: https://example.com/file.zip
網羅性チェックの威力
もしCopyingDownloadProgress
の処理を書き忘れると、コンパイラが「CopyingDownloadProgress
ケースがカバーされていません」と教えてくれます。これにより、状態の考慮漏れというありがちなバグを未然に防ぐことができます。
// コンパイルエラー!CopyingDownloadProgressが不足
final message = switch (progress) {
// CopyingDownloadProgress() => 'ファイルをコピー中...', // この行がないとエラー
ZippingDownloadProgress() => 'ZIPファイルを作成中...',
CompletedDownloadProgress(:final downloadUrl) => 'ダウンロード完了: $downloadUrl',
};
すべてにマッチする_
というワイルドカードがありますが、Unionを用いる場合は使用しないようにしましょう。せっかくの状態の網羅性というメリットを活かせなくなってしまいます。
switchを使わずとも、freezedで生成されたUnionのクラスにはwhen
というメソッドがあり、以下のようにも記述できます。ただし、これはDart 2時代の記法の名残であり、 モダンなswitch
式を使用する方が、より柔軟なパターンマッチングが可能です。
final progress = DownloadProgress.completed(downloadUrl: 'https://example.com/file.zip');
progress.when(
copying: () => print('ファイルをコピー中...'),
zipping: () => print('ZIPファイルを作成中...'),
completed: (downloadUrl) => print('ダウンロード完了: $downloadUrl'),
);
より複雑な状態管理の例
実際のアプリケーションでは、より複雑な状態管理が必要になることがあります。以下は、フォーム送信の状態を管理するUnionの例です:
@freezed
sealed class FormState with _$FormState {
const factory FormState.initial() = InitialFormState;
const factory FormState.validating() = ValidatingFormState;
const factory FormState.valid({required Map<String, String> data}) = ValidFormState;
const factory FormState.invalid({required Map<String, String> errors}) = InvalidFormState;
const factory FormState.submitting() = SubmittingFormState;
const factory FormState.submitted({required String successMessage}) = SubmittedFormState;
const factory FormState.failed({required String errorMessage}) = FailedFormState;
}
このような複雑な状態でも、パターンマッチングによって安全にハンドリングできます:
Widget buildFormButton(FormState state) {
return switch (state) {
InitialFormState() => ElevatedButton(
onPressed: _validateForm,
child: const Text('送信'),
),
ValidatingFormState() => const ElevatedButton(
onPressed: null,
child: Text('検証中...'),
),
ValidFormState() => ElevatedButton(
onPressed: _submitForm,
child: const Text('送信'),
),
InvalidFormState() => const ElevatedButton(
onPressed: null,
child: Text('入力内容を確認してください'),
),
SubmittingFormState() => const ElevatedButton(
onPressed: null,
child: SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
SubmittedFormState() => const ElevatedButton(
onPressed: null,
child: Icon(Icons.check, color: Colors.green),
),
FailedFormState() => ElevatedButton(
onPressed: _retrySubmit,
child: const Text('再試行'),
),
};
}
宣言的に状態が網羅されるため、可読性も高くなります。
まとめ
freezedのUnionとパターンマッチングを導入することで、以下のようなメリットが得られます:
- 型安全性: 状態ごとに異なる値を安全に保持できる
- 網羅性チェック: コンパイラが状態の考慮漏れを防いでくれる
- 可読性の向上: if文のネストがなくなり、宣言的でクリーンなコードになる
- 保守性の向上: 新しい状態を追加する際の影響範囲が明確
従来のenum + nullableプロパティの組み合わせと比べて、Unionは圧倒的に安全で表現力豊かな状態管理を実現します。Flutterでの状態管理を一段上のレベルに引き上げてくれるfreezedのUnion、ぜひ活用してみてください。