はじめに
Flutterでの型安全なバリデーションを設計したので、共有します。
バリデーションを漏れなくシンプルに実装できます。
目次
- 参考コード
- 前回の記事
- 型安全な設計とは
- TodoTitleの実装
- TodoTitleの使用方法
- Controller層とUsecases層の役割
参考コード
前回の記事
型安全な設計とは
title のようなユーザー入力には、以下のようなバリデーションルールが必要です。
- 入力必須
- 文字数制限(1文字以上、50文字以下)
- 日本語などの形式制限(今回は省略)
これらを毎回 String に対して実装していると
- 重複コードが増える
- 実装漏れでバグが発生する
といった問題が起こりがちです。
型を定義すると以下のメリットがあります。
- 型の時点でルール違反を防げる
- ルールをまとめられる
- ルールを再利用できる
Stringのままの場合
Before
@freezed
abstract class TodoModel with _$TodoModel {
factory TodoModel({
required String id,
required String title,
required bool isDone,
}) = _TodoModel;
factory TodoModel.fromJson(Map<String, dynamic> json) =>
_$TodoModelFromJson(json);
}
専用の型を作成した場合
After
@freezed
abstract class TodoModel with _$TodoModel {
factory TodoModel({
required String id,
required TodoTitle title,
required bool isDone,
}) = _TodoModel;
factory TodoModel.fromJson(Map<String, dynamic> json) =>
_$TodoModelFromJson(json);
}
TodoTitleを作成
TodoTitleは、バリデーションを内包した値オブジェクトです。
正しいタイトルでなければ、インスタンスを作成できない仕組みになっています。
コードを見る
/domain/todo_title.dart
/// Todoのタイトルを表す値オブジェクト
///
/// タイトルは1文字以上50文字以下である必要があります。
/// 空文字列は許可されません。
@JsonSerializable()
class TodoTitle {
/// タイトルの最大文字数
static const int maxLength = 50;
/// タイトルの最小文字数
static const int minLength = 1;
/// タイトルの値
final String value;
/// バリデーションルール
static final TextFieldValidator validator = TextFieldValidator(
maxLength: maxLength,
minLength: minLength,
);
const TodoTitle._(this.value);
/// 新しいTodoTitleを作成します。
///
/// [input]が空の場合は[ArgumentError]をスローします。
/// [input]が[maxLength]を超える場合は[ArgumentError]をスローします。
/// [input]が[minLength]未満の場合は[ArgumentError]をスローします。
///
/// 例:
/// ```dart
/// final title = TodoTitle("新しいタスク"); // OK
/// final emptyTitle = TodoTitle(""); // ArgumentError: 入力してください
/// final tooLongTitle = TodoTitle("a" * 51); // ArgumentError: 50文字以内で入力してください
/// ```
factory TodoTitle(String input) {
final error = validator.validate(input);
if (error != null) throw ArgumentError(error);
return TodoTitle._(input.trim());
}
/// JSONからTodoTitleを作成します。
///
/// [json]は'value'キーを持つMapである必要があります。
/// 値は文字列である必要があります。
factory TodoTitle.fromJson(Map<String, dynamic> json) =>
TodoTitle(json['value'] as String);
/// TodoTitleをJSONに変換します。
///
/// 戻り値は'value'キーを持つMapです。
Map<String, dynamic> toJson() => {'value': value};
@override
String toString() => value;
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is TodoTitle && other.value == value;
}
@override
int get hashCode => value.hashCode;
}
TodoTitleを使用
以下は、TodoUsecases内でTodoTitleを使って安全にタイトルを扱っている例です。
コードを見る
/application/todo_usecases.dart
class TodoUsecases {
final TodoRepository repository;
TodoUsecases(this.repository);
Future<List<TodoModel>> fetch() => repository.fetchTodos();
Future<void> add(String rawTitle) async {
try {
final title = TodoTitle(rawTitle);
final todo = TodoModel(
id: DateTime.now().toIso8601String(),
title: title,
isDone: false,
);
await repository.createTodo(todo);
} catch (e) {
throw Exception('保存できません: ${e is ArgumentError ? e.message : e}');
}
}
}
Controller層とUsecases層がある場合
バリデーションはUsecases層で行うようにします。
Controller層は状態管理のみにし、ビジネスロジックを持たないように心がけます。
Controller
- ユーザー操作を受けてstateを操作
- ユースケースの呼び出し、結果の受け取りと再描画
Usecases
- データのバリデーション
- ドメインルールに基づく変換(copyWith(...)など)
- Repositoryとのやりとり