はじめに
こんにちは、Rerurate_514と申します。
最近ずっと、freezedを使用しているのですが、これを使ってResultクラスを置き換えてみた話をしようかと思います。
Resultクラスは、その考え方を Rust と Kotlin から受け継いで実装していたものでしたが、これを Dart で 、さらに freezed で書くととても体験が良いです。
実装
では、実際に書いていきます。
まず、freezedには、sealedクラスを利用して、簡単にサブクラスを作成する方法があります。
freezedクラス内でサブクラスを作成する
これを利用します。
@freezed
class DataResult with _$DataResult {
factory DataResult.success() = DataResultSuccess;
factory DataResult.failure() = DataResultFailure;
}実際にこのように書いてから、buildすると、
DataResult fetch() {
try {
//...
return DataResult.success();
} catch(e) {
//...
return DataResult.failure();
}
}のように、利用することができます。
単に、boolで帰り値を設定するより、可読性が上がっているのではないでしょうか?
is〇〇〇〇メソッドの際にはboolを返しますが、fetchメソッドの時もboolを返している人は、もしかしたらこのように書いた方が良いかもしれません。
さらに、この方法の利点として、Resultにそれぞれ値を設定できるので、帰り値を複数持ちたいときにとても楽です。
例えば、先ほどの例。ExceptionをResultで返したいとき、
@freezed
class DataResult with _$DataResult {
factory DataResult.success() = DataResultSuccess;
factory DataResult.failure({
required Exception e
}) = DataResultFailure;
}のように書くと、先ほどの例も
DataResult fetch() {
try {
//...
return DataResult.success();
} catch(e) {
//...
return DataResult.failure(e: e);
}
}としてExceptionの情報と、関数が実行失敗した情報を同時に返すことができます。
DataResultSuccessも同様に、情報を付加することができます。
@freezed
class DataResult with _$DataResult {
factory DataResult.success({
required DateTime fetchedAt,
@Default(false) bool isFromCache,
}) = DataResultSuccess;
factory DataResult.failure({
required Exception e
}) = DataResultFailure;
}この時、データも付加するときに、ジェネリクスをすると、DataResultFailureにもそれを適用しなければなりません。
@freezed
class DataResult<T> with _$DataResult<T> {
factory DataResult.success({
required T data,
required DateTime fetchedAt,
@Default(false) bool isFromCache,
}) = DataResultSuccess<T>;
factory DataResult.failure({
required Exception e,
}) = DataResultFailure<T>;
}これでDataResultFailureを返す時に、特には問題ないですが、lintなどで警告が出る場合があります。
DataResult<dynamic> fetch() {
try {
//...
return DataResult<Data>.success(
data: data
);
} catch(e) {
//...
return DataResult<dynamic>.failure(e: e);
}
}個人的に帰り値を設定するとき、結局dynamicを設定することになるので、この構成の場合、ジェネリクスを使用することは控えた方がいいです。
インターフェースを利用した例
さらにこのように複数サブクラスを用意した際、
@freezed
sealed class FileCheckResult with _$FileCheckResult {
factory FileCheckResult.valid() = FileCheckValid;
factory FileCheckResult.invalid() = FileCheckInvalid;
factory FileCheckResult.notExist() = FileCheckNotExist;
factory FileCheckResult.failed() = FileCheckFailed;
}複数の付加情報をinterfaceとして実装することができます。
例えば、以下のクラス。
もしこのResultクラスがファイルをダウンロードして検証するときに使用するとします。
ダウンロードが成功した場合の付加情報を付けるとすると、
abstract interface class IFileDownloadSuccess {}とインターフェースを書いて、
@freezed
sealed class FileCheckResult with _$FileCheckResult {
@Implements<IFileDownloadSuccess>()
factory FileCheckResult.valid() = FileCheckValid;
@Implements<IFileDownloadSuccess>()
factory FileCheckResult.invalid() = FileCheckInvalid;
factory FileCheckResult.notExist() = FileCheckNotExist;
factory FileCheckResult.failed() = FileCheckFailed;
}のように@Implementsアノテーションを付けると、FileCheckValidとFileCheckInvalidをIFileDownloadSuccessのクラスとして扱うことができます。
これを利用すると、クラスそのものがある程度、Resultの中身を説明してくれます。
もっと実用的な例で言うと、ExceptionをResultに置き換えることができます。
abstract interface class ApiFetchError {}
@freezed
sealed class ApiFetchResult with _$ApiFetchResult {
factory ApiFetchResult.success({
required Map<String, dynamic> data,
required DateTime fetchedAt,
}) = ApiFetchSuccess;
@Implements<ApiFetchError>()
factory ApiFetchResult.networkError({
required String message,
}) = ApiFetchNetworkError;
@Implements<ApiFetchError>()
factory ApiFetchResult.serverError({
required int statusCode,
required String errorCode,
}) = ApiFetchServerError;
@Implements<ApiFetchError>()
factory ApiFetchResult.unexpectedError({
required Exception e,
required StackTrace stackTrace,
}) = ApiFetchUnexpectedError;
}このように、エラーをまとめることができます。
やはりユーザにはあまりExceptionの情報などの技術的詳細を見せたくないので、このようにラップするのが、いいかと思います。
Riverpodなんかは、throwされたらそのまま表示しちゃいますからね。
switchによる網羅分岐
sealedクラスを使用しているので、switchによる網羅分岐を使用することができます。
@freezed
class DataResult with _$DataResult {
factory DataResult.success({
required Data data,
}) = DataResultSuccess;
factory DataResult.failure({
required Exception e,
}) = DataResultFailure;
}
DataResult fetch() {
try {
//...
return DataResult.success(
data: data
);
} catch(e) {
//...
return DataResult.failure(e: e);
}
}
final result = fetch();
switch(result) {
case DataResultSuccess(): {
print("取得されたデータ:${result.data}");
}
case DataResultFailure(): {
print("返された例外:${result.e}$");
}
}sealedを使用しているときの、網羅分岐はエディタが勝手に分岐を作成してくれるので、体験が良いです。もちろんifによる型チェック(is)分岐を使用してもよいです。
拡張関数による利便性の向上
もちろん、拡張関数を使用することで利便性が向上します。
例えば、以下のコード。
@freezed
class DataResult with _$DataResult {
factory DataResult.success({
required Data data,
}) = DataResultSuccess;
factory DataResult.failure({
required Exception e,
}) = DataResultFailure;
}
extension DataResultEx on DataResult {
bool get isSuccess => this is DataResultSuccess;
bool get isFailure => this is DataResultFailure;
Data? get dataOrNull => map(
success: (s) => s.data,
failure: (_) => null,
);
}このように書くと、サクッと書くことができます。
if(result.isSucecss) //...
final data = result.dataOrNullおわり
最近、このようにResultを活用しているのですが、前より可読性が向上した気がします。
自分はプリミティブ型をそのまま使用するのが好きではないので、このようにいつも書いています。
プリミティブ型は基本的に、データクラスの中だけにしています。不変ってさいこー^^
Dart には一応値を複数返すことができるRecord型が存在しますが、
(String, int) getUser() {
return ('Rerurate', 20);
}
void main() {
final user = getUser();
print(user.$1);
print(user.$2);
}このように、はたから見ると訳の分からないコードになってしまうので、Resultを使用したほうがいいです。
自分は勉強している中で、成長による技術的負債が溜まりがちなので、もしかしたらこの記事もそのうち、自分にとって技術的負債になってしまうかもしれませんね。