Look Mom I made a new pattern!
Choose what language you want the article in :)
It’s often the case that dependency inversion patterns do a good job of hiding some implementation details, like what dependencies are required to instantiate some specific implementation or what backend/data retrieval process is being exactly utilized. However they are bad at hiding other implementation details, namely most effects. If your repository’s implementation has functions that require asynchronicity, error handling, or branching capability, those details will leak into the abstraction, otherwise the implementation’s function signature wouldn’t be valid.
The Environment Function Link to heading
This pattern differs from most other dependency inversion patterns in that we’re not defining an abstract type to hold a bunch of abstract functions, we just want one abstract function.
So while you might be used to something like:
trait MatchInfoSource:
def fetchPlayerIds: List[String]
def fetchMatchIds(playerId: String): List[String]
def fetchMatch(matchId: String): String
We’d instead have each function in its own abstraction.
// apply is how we call an object as a function
trait FetchPlayerIds { def apply: List[String] }
trait FetchMatchIds { def apply(playerId: String): List[String] }
trait FetchMatch { def apply(matchId: String): String }
At first this probably sounds terrible, you’d have some crazy parameter explosion. Where previously you might have required 3 interfaces, you’d now need easily a dozen!
This is where another important component of the pattern comes in, the functions are actually required by the environment. What this means exactly is dependent on the language/framework. Sometimes what looks like just normally calling the function is actually requiring the function in the current environment and then calling it. Other times you’re building up sequence of data structures that will be later interpreted. For the purposes of this pattern, these two approaches are very similar.
Now what does “required by the environment” actually mean? It means rather than passing arguments down a chain of function calls, dependencies are resolved by some other mechanism. There are several things to consider here
- Are dependencies resolved at compile time or runtime?
- Does our method of dependency management compose with other effects?
- How much boilerplate is there for resolving dependencies?
In FP Scala, we generally reach for the reader effect. In transformer code this means specifically using ReaderT, in kyo it means Env, and in mtl it means the Reader context bound. You can read my explanation on readers here. In these contexts, effects are first class and we can compose them seamlessly
def fetchAndSave(userId: Int) = for
db: Database <- ask
api: HttpClient <- ask
user <- api.fetch(userId)
_ <- db.save(user)
yield ()
Take this pseudocode:
function fetchAndSaveGame()
ids = queryMatchIds()
trackMatchIds(ids)
playerId = fetchPlayerId()
matchId = fetchMatchId(playerId)
skipRestIfMatchTracked(matchId)
trackMatchIds([matchId])
match = fetchMatch(matchId)
insertMatch(match)
trackSuccess
Modern languages have generally adopted effect tracking, and while typed languages have much more robust effect tracking, even untyped languages like Python or Javascript have some level of effect invocation with keywords like async/throws/for. While arguably nothing is tracked because there are no real types (just runtime tags often called “types”), you still see the same boilerplate introduced.
Let’s say you’re inspired by the environment function pattern and want to go implement it in some language without support for first class effects. Notably, since it’s a real application, effects happen. Here’s an example of a set of effects you might implement:
It’s often the case that dependency inversion patterns do a good job of hiding some implementation details, like what dependencies are required to instantiate some specific implementation or what backend/data retrieval process is being exactly utilized. However they are bad at hiding other implementation details, namely most effects. If your repository’s implementation has functions that require asynchronicity, error handling, or branching capability, those details will leak into the abstraction, otherwise the implementation’s function signature wouldn’t be valid.
The Environment Function Link to heading
This pattern differs from most other dependency inversion patterns in that we’re not defining an abstract type to hold a bunch of abstract functions, we just want one abstract function.
So while you might be used to something like:
data MatchInfoSource m = MatchInfoSource
{ fetchPlayerIds :: m [String]
, fetchMatchIds :: String -> m [String]
, fetchMatch :: String -> m String
}
We’d instead have each function in its own abstraction.
-- Using longer GADT syntax here instead of newtypes for later use with polysemy
data FetchPlayerIds m a where FetchPlayerIds :: FetchPlayerIds m [String]
data FetchMatchIds m a where FetchMatchIds :: String -> FetchMatchIds m [String]
data FetchMatch m a where FetchMatch :: String -> FetchMatch m String
At first this probably sounds terrible, you’d have some crazy parameter explosion. Where previously you might have required 3 interfaces, you’d now need easily a dozen!
This is where another important component of the pattern comes in, the functions are actually required by the environment. What this means exactly is dependent on the language/framework. Sometimes what looks like just normally calling the function is actually requiring the function in the current environment and then calling it. Other times you’re building up sequence of data structures that will be later interpreted. For the purposes of this pattern, these two approaches are very similar.
Now what does “required by the environment” actually mean? It means rather than passing arguments down a chain of function calls, dependencies are resolved by some other mechanism. There are several things to consider here
- Are dependencies resolved at compile time or runtime?
- Does our method of dependency management compose with other effects?
- How much boilerplate is there for resolving dependencies?
In Haskell, we generally reach for the reader effect. In transformer code this means specifically using ReaderT, in polysemy and mtl it means having a Reader constraint (in slightly different forms though). You can read my explanation on readers here. In these contexts, effects are first class and we can compose them seamlessly
fetchAndSave userId = do
source <- ask @Database
api <- ask @HttpClient
user <- api.fetch(userId)
db.save(user)
It’s often the case that dependency inversion patterns do a good job of hiding some implementation details, like what dependencies are required to instantiate some specific implementation or what backend/data retrieval process is being exactly utilized. However they are bad at hiding other implementation details, namely most effects. If your repository’s implementation has functions that require asynchronicity, error handling, or branching capability, those details will leak into the abstraction, otherwise the implementation’s function signature wouldn’t be valid.
The Environment Function (part 1) Link to heading
This pattern differs from most other dependency inversion patterns in that we’re not defining an abstract type to hold a bunch of abstract functions, we want separate abstract functions.
So while you might be used to something like:
protocol MatchInfoSource {
func fetchPlayerIds() -> [String]
func fetchMatchIds(playerId: String) -> [String]
func fetchMatch(matchId: String) -> String
}
We’d instead have each function in its own abstraction.
// callAsFunction is how we call an object as a function
protocol FetchPlayerIds { func callAsFunction() -> [String] }
protocol FetchMatchIds { func callAsFunction(playerId: String) -> [String] }
protocol FetchMatch { func callAsFunction(matchId: String) -> String }
What this allows is for implementations to not specify more than they have to. If we have some function that requires fetchPlayerIds, we don’t want it to require fetchMatchIds and fetchMatch just because those 3 functions are tied together in a protocol.
This does mean however that we’ll require more dependencies overall. Requiring the dependencies at a value level in this pattern will be essentially automatic because they’ll be required by the environment, and at the type level we can still combine dependencies into groups if they’re often required together.
What “required by the environment” means exactly is dependent on the language/framework. Sometimes what looks like just normally calling the function is actually requiring the function in the current environment and then calling it. Other times you’re building up sequence of data structures that will be later interpreted. For the purposes of this pattern, these two approaches are very similar.
Current Usage in Swift Link to heading
As it stands right now, the cost of this pattern is likely too high in Swift to commonly use, because there are certain abstractions that are impossible to make in Swift. There is a good chance this will change down the line. In the Generics Manifesto there are essentially three categories of items. Those at the top that are implicitly “we want to add these”, and then towards the bottom the “Maybe” and “Unlikely” sections. Generic associated types are near the top, while higher kinded types are in the “Maybe” section.
The rest of the article will have code examples in 3-4 different formats. GAT (Generic Associated Types), HKT (Higher Kinded Types), Vanilla (idiomatic swift), and Gross (attempting to do the pattern in modern swift, often extremely verbose and even unsafe).
GATs are simply adding the capability for associated types to themselves also be generic, this is something Rust added and Swift has been taking a lot of inspiration from Rust lately. HKTs assumes certain restrictions around constraints and single protocol conformances are lifted, so that requires a much deeper redesign of the type system, making it far less likely. The codeblocks will also have optional “Explanation” blocks afterwards, and a + sign will be attached to the tab if it has an explanation.
To showcase this, here’s a small example of a Mappable protocol in the different versions (this would generally be in a library):
protocol Mappable {
associatedtype Context<T> // this is the only impossible line
static func map<A, B>(_ fa: Context<A>, _ f: (A) -> B) -> Context<B>
}
extension Array: Mappable {
typealias Context<T> = Array<T>
static func map<A, B>(_ fa: Array<A>, _ f: (A) -> B) -> Array<B> {
fa.map(f)
}
}
extension Optional: Mappable {
typealias Context<T> = Optional<T>
static func map<A, B>(_ fa: Optional<A>, _ f: (A) -> B) -> Optional<B> {
fa.map(f)
}
}
extension Task: Mappable where Failure == Error {
typealias Context<T> = Task<T, Error>
static func map<A, B>(_ fa: Task<A, Error>, _ f: @escaping (A) -> B) -> Task<B, Error> {
Task {
let a = await fa.value
return f(a)
}
}
}
extension Result: Mappable where Failure == Error {
typealias Context<T> = Result<T, Error>
static func map<A, B>(_ fa: Result<A, Error>, _ f: (A) -> B) -> Result<B, Error> {
fa.map(f)
}
}
protocol Mappable<F<_>> {
static func map<A, B>(_ fa: F<A>, _ f: (A) -> B) -> F<B>
}
extension Array: Mappable<Array> {
static func map<A, B>(_ fa: Array<A>, _ f: (A) -> B) -> Array<B> {
fa.map(f)
}
}
extension Optional: Mappable<Optional> {
static func map<A, B>(_ fa: Optional<A>, _ f: (A) -> B) -> Optional<B> {
fa.map(f)
}
}
extension Task: Mappable<Task> where Failure == Error {
static func map<A, B>(_ fa: Task<A, Never>, _ f: @escaping (A) -> B) -> Task<B, Error> {
Task {
let a = await fa.value
return f(a)
}
}
}
extension Result: Mappable<Result> where Failure == Error {
static func map<A, B>(_ fa: Result<A, Error>, _ f: (A) -> B) -> Result<B, Error> {
fa.map(f)
}
}
extension<A> F<A> where F: Mappable<F> {
func fmap<B>(_ f: @escaping (A) -> B) -> F<B> {
F.map(self, f)
}
}
Explanation:
If you're unfamiliar with HKTs (higher kinded types), you can think of them as
generics for generics.
A generic type like `T` can be `String`, `Int`, `List<String>`, or `Option<Int>`.
A generic that itself is "also generic" would be `F<_>`, and `F` can a type
constructor like `List` or `Option`. These are types that take generics. This is
the only version that can define a generic `F` that is later filled with a
type (specifically `F<Int>` in the above example).
// This can't safely be done in an idiomatic way in modern swift
// This mimics the Bow library where they emulate HKTs with `Kind`
class Kind<F, A> {}
protocol Mappable {
static func map<A, B>(
_ fa: Kind<Self, A>,
_ f: @escaping (A) -> B
) -> Kind<Self, B>
}
final class ForArray: Mappable {
static func map<A, B>(
_ fa: Kind<ForArray, A>,
_ f: @escaping (A) -> B
) -> Kind<ForArray, B> {
ArrayK(fa.fix().value.map(f))
}
}
final class ArrayK<A>: Kind<ForArray, A> {
let value: [A]
init(_ value: [A]) { self.value = value }
}
extension Kind where F == ForArray {
// CAN FAIL, NOT ACTUALLY SAFE
func fix() -> ArrayK<A> { self as! ArrayK<A> }
}
final class ForOptional: Mappable {
static func map<A, B>(
_ fa: Kind<ForOptional, A>,
_ f: @escaping (A) -> B
) -> Kind<ForOptional, B> {
OptionalK(fa.fix().value.map(f))
}
}
final class OptionalK<A>: Kind<ForOptional, A> {
let value: A?
init(_ value: A?) { self.value = value }
}
extension Kind where F == ForOptional {
// CAN FAIL, NOT ACTUALLY SAFE
func fix() -> OptionalK<A> { self as! OptionalK<A> }
}
final class ForTask: Mappable {
static func map<A, B>(
_ fa: Kind<ForTask, A>,
_ f: @escaping (A) -> B
) -> Kind<ForTask, B> {
TaskK(Task<B, Error> {
let a = try await fa.fix().value.value
return f(a)
})
}
}
final class TaskK<A>: Kind<ForTask, A> {
let value: Task<A, Error>
init(_ value: Task<A, Error>) { self.value = value }
}
extension Kind where F == ForTask {
// CAN FAIL, NOT ACTUALLY SAFE
func fix() -> TaskK<A> { self as! TaskK<A> }
}
final class ForResult: Mappable {
static func map<A, B>(
_ fa: Kind<ForResult, A>,
_ f: @escaping (A) -> B
) -> Kind<ForResult, B> {
ResultK(fa.fix().value.map(f))
}
}
final class ResultK<A>: Kind<ForResult, A> {
let value: Result<A, Error>
init(_ value: Result<A, Error>) { self.value = value }
}
extension Kind where F == ForResult {
// CAN FAIL, NOT ACTUALLY SAFE
func fix() -> ResultK<A> { self as! ResultK<A> }
}
func add4Inside<F: Mappable>(_ fa: Kind<F, Int>) -> Kind<F, Int> {
F.map(fa) { $0 + 4 }
}
add4Inside(ArrayK([1,2,3,4])).fix().value //> [5,6,7,8]
let opt: Int? = 4
add4Inside(OptionalK(opt)).fix().value //> Optional(8)
Explanation:
// The pattern here is a lot more verbose, and less safe, than GAT and HKTs.
// While in the HKT to implement Mappable just required:
extension Array: Mappable<Array> {
static func map<A, B>(_ fa: Array<A>, _ f: (A) -> B) -> Array<B> {
fa.map(f)
}
}
// this instead requires:
final class ForArray: Mappable {
static func map<A, B>(
_ fa: Kind<ForArray, A>,
_ f: @escaping (A) -> B
) -> Kind<ForArray, B> {
ArrayK(fa.fix().value.map(f))
}
}
final class ArrayK<A>: Kind<ForArray, A> {
let value: [A]
init(_ value: [A]) { self.value = value }
}
extension Kind where F == ForArray {
func fix() -> ArrayK<A> { self as! ArrayK<A> }
}
// More important than all the extra verbosity, `fix` is actually unsafe.
// We could use it on an ArrayK and it would be fine,
// but we could just as easily implement some other Kind<ForArray, A>` class.
// Then fix() would fail on that new object.
and then usage of each (this is what you’d normally see in your codebase):
func add4Inside<F: Mappable>(_ fNum: F.Context<Int>) -> F.Context<Int> {
F.map(fNum) { $0 + 4 }
}
func main() {
let nums: [Int] = [1,2,3,4]
let opt: Int? = 4
let task: Task<Int, Error> = Task { 4 }
let result: Result<Int, Error> = .success(10)
add4Inside(nums) //> [5,6,7,8]
add4Inside(opt) //> Optional(8)
add4Inside(task) //> Task(8)
add4Inside(result) //> Result.Success(14)
}
func add4Inside<F<_>: Mappable<F>>(_ numF: F<Int>): F<Int> {
numF.fmap { $0 + 4 }
}
func main() {
let nums: [Int] = [1,2,3,4]
let opt: Int? = 4
let task: Task<Int, Error> = Task { 4 }
let result: Result<Int, Error> = .success(10)
add4Inside(nums) //> [5,6,7,8]
add4Inside(opt) //> Optional(8)
add4Inside(task) //> Task(8)
add4Inside(result) //> Result.Success(14)
}
func add4Inside(_ numOpt: Int?): Int? {
numOpt.map { $0 + 4 }
}
func add4Inside(_ nums: [Int]): [Int] {
nums.map { $0 + 4 }
}
func add4Inside(_ numTask: Task<Int, Error>): Task<Int, Error> {
Task {
let value = await numTask.value
return value + 4
}
}
func add4Inside(_ numResult: Result<Int, Error>) -> Result<Int, Error> {
numResult.map { $0 + 4 }
}
func main() {
let nums: [Int] = [1,2,3,4]
let opt: Int? = 4
let task: Task<Int, Error> = Task { 4 }
let result: Result<Int, Error> = .success(10)
add4Inside(nums) //> [5,6,7,8]
add4Inside(opt) //> Optional(8)
add4Inside(task) //> Task(8)
add4Inside(result) //> Result.Success(14)
}
func add4Inside<F: Mappable>(_ fa: Kind<F, Int>) -> Kind<F, Int> {
F.map(fa) { $0 + 4 }
}
func main() {
let nums: [Int] = [1,2,3,4]
let opt: Int? = 4
let task: Task<Int, Error> = Task { 4 }
let result: Result<Int, Error> = .success(10)
add4Inside(ArrayK(nums)).fix().value //> [5,6,7,8]
add4Inside(OptionalK(opt)).fix().value //> Optional(8)
add4Inside(TaskK(task)).fix().value //> Task(8)
add4Inside(ResultK(result)).fix().value //> Result.Success(14)
}
If Mappable makes sense, congratulations you understand what Functors are and how to use them, Mappable is just a different name for Functors.
Dependencies Link to heading
Now back to the pattern, what does “required by the environment” actually mean? It means rather than passing arguments down a chain of function calls, dependencies are resolved by some other mechanism. There are several things to consider here
- Are dependencies resolved at compile time or runtime?
- Does our method of dependency management compose with other effects?
- How much boilerplate is there for resolving dependencies?
Readers are a fantastic way to manage dependencies if you can write monadic code. The dependencies are resolved at compile time, they do compose in the same way we compose other effects with monads, and there’s either very little boilerplate if you have HKT/GATs.
First let’s focus on what a reader actually is, here’s the minimal viable definition of a Reader:
struct Reader<Env, A> {
let run: (Env) -> A
}
They’re really just functions with different semantics. Everything you can do with a Reader you can do with a function. The difference in semantics is that the input to the function is viewed as the “environment” that a value exists in. So while we might think of a function as having an input and an output, a reader has a value (output) that exists in an environment (input).
This means that managing dependencies with just the Reader struct can be viewed the same way as managing dependencies with just parameters to a function. We will use other abstractions on top of this to make it much more powerful though.
The most common operation you’ll see with readers is ask:
static func ask<Env>() -> Reader<Env, Env> {
Reader { env in env }
}
With function semantics, this is just the identity function (x -> x), in the context of readers you’re asking for the current environment.
Effect Fundamentals Link to heading
When thinking about effects though, we want to change our perspective from concrete data structures to thinking about capabilities. This is where the idea of a monad comes in. Monads allow us to bind/sequence effects together, and whatever monad we’re operating in defines our set of capabilities. Monad requires two operations
protocol Monad {
associatedtype M<T>
static func pure<A>(_ value: A) -> M<A>
static func flatMap<A, B>(_ ma: M<A>, _ f: (A) -> M<B>) -> M<B>
}
protocol Monad<M<_>> {
static func pure<A>(_ value: A) -> M<A>
static func flatMap<A, B>(_ ma: M<A>, _ f: (A) -> M<B>) -> M<B>
}
class Kind<F, A> {}
protocol Monad {
static func pure<A>(_ value: A) -> Kind<Self, A>
static func flatMap<A, B>(
_ ma: Kind<Self, A>,
_ f: @escaping (A) -> Kind<Self, B>
) -> Kind<Self, B>
}
Explanation:
While the definition here isn't too much worse than the other versions, actual
implementations are far worse, both in terms of verbosity and safety, as seen by
the `Mappable` example at the start of the article.
flatMap is often called bind (or >>=), since that describes what it is rather than how it does it. With flatMap you think “map then flatten”, with bind you think “bind effects”. Only choosing flatMap since that’s the norm in Swift.
The first of these two operations is pure, which is for putting a value into a monadic context. For example, if you want to put the value 4 into the List monad:
// Assuming we or a library defined an `extension List : Monad`
let nums: [Int] = .pure(4) //> [4]
The second operation flatMap let’s you combine two effects. What this mean depends on the effect. Let’s take the Result monad as an example:
// Assuming we or a library defined an `extension Result : Monad`
let someSuccess: Result<Int, String> = .flatMap(.success(4)) { .success($0 * $0) }
//> Result.Success(16)
let someFailure: Result<Int, String> = .flatMap(.failure("no")) { .success($0 + 1) }
//> Result.Failure("no")
The result monad gives us short-circuiting capability (usually given by Either in FP languages). While Swift throws is much better than say Java or other languages that don’t have type-safe throws, it still doesn’t compose with other effects nicely.
Effectful Dependencies Link to heading
With an understanding of Monad and Reader, we can now define MonadReader. MonadReader isn’t the monad implementation for reader, it’s rather “some monad that has reader-like capabilities”. Of course, Reader fits this description, but many other types can too. The hierarchy here goes:
Functor <- Monad <- MonadReader <- Reader
Here is the MonadReader:
protocol MonadReader: Monad {
associatedtype Env
static func ask() -> M<Env>
static func local<A>(_ transform: (Env) -> Env, _ ma: M<A>) -> M<A>
}
More Effects Link to heading
You can similarly define other “effects” like MonadTask that encapsulates the capability of Task, or MonadError that has the capability of Result’s short-circuiting. These capabilities are very similar to async and throws, but instead of requiring different (and sometimes combined) syntax to unwrap them, you always use <- to unwrap them.
protocol HasDatabase { func database: Database }
protocol HasHttpClient { func httpClient: HttpClient }
func fetchAndSave<M: MonadReader>(userId: Int)-> M.M<()>
where M.Env = HasDatabase & HasHttpClient {
#monadic {
api <- M.ask().map { $0.httpClient }
db <- M.ask().map { $0.database }
user <- api.fetch(userId: userId)
db.save(user: user)
}
}
Explanation:
// #monadic is a freestanding macro, with the addition of generic associated
// types, this macro should be possible to make with no additional language
// changes. It just converts it into a chain of flatMap calls,
// this being equivalent to the above:
func fetchAndSave<M: MonadReader>(userId: Int)-> M.M<()>
where M.Env = HasDatabase & HasHttpClient {
M.ask().map { $0.httpClient }.flatMap { api in
M.ask().map { $0.database }.flatMap { db in
api.fetch(userId: userId).flatMap { user in
db.save(user: user)
}
}
}
}
func fetchAndSave<M<_>: MonadReader<M, Database> & MonadReader<M, HttpClient>
& MonadTask<M> & MonadError<M, Error>>(userId: Int) -> M<()> {
#monadic {
api: HttpClient <- M.ask()
db: Database <- M.ask()
user <- api.fetch(userId: userId)
db.save(user: user)
}
}
Explanation:
// The above implementation doesn't require separate protocols for requiring
// dependencies like the GAT version. Now we're starting to get into some of the
// benefits of HKTs over GATs.
// The main thing that makes this code pretty verbose is the types. If we wrote
// this code with the level of type inference Hindley-Milner type inference
// offers (often found in pure FP languages) we'd be down to:
func fetchAndSave(userId) {
#monadic {
api: HttpClient <- ask()
db: Database <- ask()
user <- api.fetch(userId: userId)
db.save(user: user)
}
}
import NeedleFoundation
protocol FetchAndSaveDependency: Dependency {
var database: Database { get }
var httpClient: HttpClient { get }
}
class FetchAndSave: Component<FetchAndSaveDependency> {
func run(userId: Int) async throws {
let api: HttpClient = dependency.httpClient
let db: Database = dependency.database
let user = try await api.fetch(userId: userId)
try await db.save(user: user)
}
}
Explanation:
In Swift, we generally reach for DI frameworks to solve this. DI frameworks
can resolve at either compile time or runtime based on the library, generally
runtime-based DI has less boilerplate, with the obvious downside of pushing
errors to runtime.
This method of dependency management does not compose with other effects at all
though. We can't pull dependencies "out" of the same type that allows us to
resolve things like async, errors, or nondeterminism. These all require separate
syntax.
The Environment Function (part 2) Link to heading
Now that we have a better idea of how our effect system works, let’s implement our environment functions again:
protocol EnvFn {
associatedtype M<_>: Monad
associatedtype Input
associatedtype Output
func run(_ input: Input) -> M<Output>
}
extension EnvFn {
static func callAsFunction(_ input: Input) -> M<Output>
where M: MonadReader<M, Self> { M.flatMap(M.ask()) { $0.run(input) } }
}
private typealias MR<M<_>, N<_<_>>> = MonadReader<M, N<M>>
protocol HasApi<M> = MR<M, FetchPlayerId> & MR<M, FetchMatchId> & MR<M, FetchMatch>
protocol HasDb<M> = MR<M, QueryMatchIds> & MR<M, InsertMatch>
protocol HasTracking<M> = MR<M, SkipRestIfMatchIdTracked> & MR<M, TrackMatchids> & MR<TrackSuccess>
protocol FetchPlayerId<M<_>>: EnvFn where Input == Void, Output == String {}
protocol FetchMatchid<M<_>>: EnvFn where Input == String, Output == String {}
protocol FetchMatch<M<_>>: EnvFn where Input == String, Output == String {}
protocol QueryMatchIds<M<_>>: EnvFn where Input == Void, Output == [String] {}
protocol InsertMatch<M<_>>: EnvFn where Input == Match, Output == Void {}
protocol SkipRestIfMatchIdTracked<M<_>>: EnvFn where Input == String, Output == Void {}
protocol TrackMatchIds<M<_>>: EnvFn where where Input == [String], Output == Void {}
protocol TrackSuccess<M<_>>: EnvFn where Input == Void, Output == Void {}
func fetchAndSaveGame<M<_>: HasApi & HasDb & HasTracking>() -> M.M<Void> {
#monadic {
ids <- QueryMatchIds()
_ <- TrackMatchIds(ids)
playerId <- FetchPlayerId()
matchId <- FetchMatchId(playerId)
_ <- SkipRestIfMatchIdTracked(matchId)
_ <- TrackMatchIds([matchId])
match <- FetchMatch(matchId)
_ <- InsertMatch(match)
TrackSuccess()
}
}
`
Explanation:
The EnvFn definition here is to help us with the boilerplate of the pattern. Swift's static inheritance is incredibly helpful for reducing protocol boilerplate.