🧛

TCAでTaskResultを使う場合に副作用の出力型を用意する案

2023/08/24に公開

はじめに

The Composable Architecture(TCA)で非同期実行した副作用の結果を受け止めるActionがある場合、TaskResult型を使うことになります。そのTaskResultは現状は次のようにenumとなっており、ジェネリクスによって成功時の型を決定できるようになっているのですが、その成功時の型をVoidにすると困ることがあり、「私はこうしている」というのが今回書こうと思っていることです。

TaskResultは次のとおりのやつです。

https://github.com/pointfreeco/swift-composable-architecture/blob/adc61d97b81fc28eda69cf5346de52490568544e/Sources/ComposableArchitecture/Effects/TaskResult.swift#L105-L110

次の記事に触発されました。

https://qiita.com/Ryu0118/items/e1ce61f48b6b3797c5da

私の結論を最初に書いておくと、
タイトルの通り「TCAでTaskResultを使う場合に副作用の出力型を用意する」ということになります。

TaskResultでVoid使うと何が困るのか

何が困るのかというと、VoidはEqutableに準拠していないので、TaskResultの成功時の型をVoidにしようと思っても、Actionの結果を受け取るenum case側がEqutableに準拠できなくてコンパイルできるのが困ります。

例えば次のような削除UIから非同期実行され結果をdidRemoveが受け取る場合に、TaskResult<Void>を用意してみるとコンパイルエラーになるのがわかるはずです。

    public enum Action: Equatable {
        public enum ViewAction: Equatable {
            case didTapRemove
        }

        public enum InternalAction: Equatable {
            case didRemove(TaskResult<Void>) // これはコンパイルエラー
        }

        case view(ViewAction)
        case `internal`(InternalAction)
    }

これが困るよね、という話です。

地道にやってみようとする

でもActionをEqutableに準拠させるだけです。Xcodeは次のようにpublic static func == (}の候補を出してくるので、そのメソッドを実装してみます。

    public enum Action: Equatable {
        public enum ViewAction: Equatable {
            case didTapRemove
        }

        public enum InternalAction: Equatable {
            public static func == (
                lhs: InternalAction,
                rhs: InternalAction
            ) -> Bool {
                switch (lhs, rhs) {
                case (.didRemove(.success), .didRemove(.success)):
                    // Void同士を比較して同じとみなす
                    return true

                case (.didRemove(.failure(let error1)), .didRemove(.failure(let error2))):
		    // errorがEqutableに準拠してたら比較し、そうでなければfalse返す
		    // というのを頑張って実装する。
                    return 
		    
		case (.didRemove, .didRemove):
		    // これは上記のcaseを除く成功と失敗を比較なのでfalse
		    return false
		    
                default:
		    // 他のcaseはEqutableに準拠してるんでしょうきっと
                    return lhs == rhs
                }
            }
	
            case didRemove(TaskResult<Void>)
        }

        case view(ViewAction)
        case `internal`(InternalAction)
    }

(私はこれをやったことないんですが)
これが困るのは次のようなことだと思います。

  • VoidのActionがあるとこのだらだらとしたコードを書くのが面倒
  • errorを比較するコードを書くのも頑張りが必要

これでも別に全然問題なしだよというならそれでいいと思います。

答えは複数あり、そのチームのやり方次第なんですよね。

自分のやっていること

個人的に自分がやるならVoidを使わず、その副作用実行の型に関連する「出力結果の型」を作って利用します。

例があるとわかりやすいはずですんで、FirestoreClientという型があるとして、その出力を型としてものを次に示します。

まずTaskResult

TaskResult<FirestoreClient.Output.Remove.Food>

FirestoreClient.Output.Remove.Foodとは何かというと、次のようにstructでカラで実装しています。コレクションFoodsから入力されたものに該当するものを1件削除する例を示します。

public struct FirestoreClient: Sendable {
    public enum Output {
        public enum Remove {
            public struct Food: Equatable {}
        }
    }
...

    // メソッドはこういう感じ
    public var removeFood: @Sendable (Input.Remove.Food) async throws -> Output.Remove.Food

このようにするのは自分の中でデメリットよりメリットが上回っているからです。

自分の方法で自分がメリットと感じること

  • 非同期で削除されたFoodが何かということが運用時に知りたくなった場合そのようなコードにも変更できる
    • public struct Food: Equatable { let id: String; public init(id: String) {...} }
    • 質問: 最初からじゃあid返すようにしとけば?
      • 解答: いや、使わないのにid返すのは無駄だしそれはやらない
        • 質問: 使わないOutput.Remove.Food()を定義するのは無駄じゃないんか?
          • 解答: そもそも入力にInputを使っているから抵抗ない
  • 副作用の入力が複雑ならInputやFailureという型をつくるからOutputと揃う
    • 対象がFirestoreの際に実はidだけではなく構造によって複数の情報を渡すことがある
    • 対象がDBのUpdateなら更新したい内容を複数渡すメソッドにしたいことがある
      • 例えばFood型のnameとstocksを渡すメソッドにしたいなど
        • nameだけ変更するメソッドにしてるとメソッドの数が多くなる

経験上、データの更新処理は自分がやろうと思っているから実行しているわけで、結果にその内容がわかる必要がない場合がほとんどです。具体的な例を考えると、更新したいidを渡して更新してるんだから、その結果idを受け取る必要なんてないように思えます。でもインターフェースとして、それが結果に含まれていることで何を送ったかを記憶しておく必要がなく、都合が良いことだってあります。そういう呼び出し側の都合を知らないとき、成功したものを返す仕組みが役に立つこともあるし、そのような設計と実装にすることもあるってことです。

また、Inputの話は脱線しそうですが、具体的にはInputやFailure型を含めると次のような感じをよくやります。

public struct FirestoreClient: Sendable {
...
    public enum Failure: Error {
        case ...
    }

    public enum Input {
        public typealias FoodID = String
        public enum Fetch { 
	    public struct Foods { ... }
	}

        public enum Remove {
            public struct Food { ... }
        }
    ...
    }
  
    public enum Output {
        public enum Remove {
            public struct Food: Equatable {}
        }
    }
    
    public var removeFood: @Sendable (Input.Remove.Food) async throws -> Output.Remove.Food

なので、副作用の型を決めるときには基本的に入力と出力とエラー列挙の型をざくっと用意するからそれを使うというのが理由です。

脱線: なぜVoidがEqutableではないのかを考えてみる

Swift言語の開発者がなぜVoid型をEqutableにしなかったのかを考えてみます。Void型がEqutableじゃない理由を考えているので、私が結果ありきで考えていますからバイアスがあるだろうとは思っています。

Voidが比較できないのはなぜか

私の考えです

  • SwiftのVoid型はVoidをモデリングして型としたものであり、それは比較のしようがない
  • 比較したければカスタムなVoid型を自分のアプリで作って利用すればいいので、Swift.Void型は比較できなくていい

つまりプログラミングとしてモデリングされる前の野生のVoidとは何かがあるのでしょう。
Voidが何かを辞書で調べるのはつまらないし「VoidはVoidだよ」というのはつまらなくて私にとって価値がないので、以下にだらっと書いてみます。

野生のVoidとは何か

自分がVoidと言われてそれが名詞であると認識した上で想起されるものはまずは間取りです。

https://www.dultonhome.com/house/b/

私が目にするVoidは間取りの吹き抜けの意味に使われるのが最も多いです。

吹き抜けという限られた空間に名前をつけているわけで、これを比較しましょうというと次のように考えるかもしれません。

  • 別の建物のVoidとこの部屋のVoidは違うものであり比較できそう
  • 同じ間取りだが別の建物のVoidは空間の大きさは同じだから比較はできそう

そうなるとそれって縦横高さがあるってことで、struct 間取りのVoid: Equtable { let 高さ ... }ってことをしたくなる。つまりVoid型っていうのは本当に何もないものなんじゃないだろうか。何か測定できるものがあればそれはVoid型ではないということになる。

Void型とは

野生のVoidと比較してSwiftのVoidについて整理すると

  • 「存在自体はあるものの測定したり比較できるもの」についてはSwift.Void型にしない
    • つまり、存在はあって測定できない場合にSwift.Void型を使う
      • なのでSwift.Void型の比較という考え自体に意味をみいだせなくなる

カスタムなVoidはあっていいと思う

私としては、カスタムなVoidを作りたいならそれはできるんだからそれがEqutableにしたいならそれでいいとも思います。SwiftのVoidはEqutableではないが、自作VoidをEqutableにすることは一向に構わないと感じてます。

あと野生のVoidにはUnlimited Void(またはInfinite Void)というのもあります。

https://jujutsu-kaisen.fandom.com/wiki/Unlimited_Void

まとめ

TCAは自由度が高いのが面白いところだと思うので、他の人がどうやってるかは興味がありますよね。

Discussion