はじめに

こんにちは、Rerurate_514と申します。
最近ずっと、freezedを使用しているのですが、これを使ってResultクラスを置き換えてみた話をしようかと思います。
Resultクラスは、その考え方を RustKotlin から受け継いで実装していたものでしたが、これを 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にそれぞれ値を設定できるので、帰り値を複数持ちたいときにとても楽です。
例えば、先ほどの例。ExceptionResultで返したいとき、

@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アノテーションを付けると、FileCheckValidFileCheckInvalidIFileDownloadSuccessのクラスとして扱うことができます。
これを利用すると、クラスそのものがある程度、Resultの中身を説明してくれます。

もっと実用的な例で言うと、ExceptionResultに置き換えることができます。

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を使用したほうがいいです。

自分は勉強している中で、成長による技術的負債が溜まりがちなので、もしかしたらこの記事もそのうち、自分にとって技術的負債になってしまうかもしれませんね。