EP 196 · Async Composable Architecture · Jul 11, 2022 ·Members

Video #196: Async Composable Architecture: Tasks

smart_display

Loading stream…

Video #196: Async Composable Architecture: Tasks

Episode: Video #196 Date: Jul 11, 2022 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep196-async-composable-architecture-tasks

Episode thumbnail

Description

This week we start fixing the problems we outlined last week. We build the tools necessary to start using concurrency tasks directly in reducers, and along the way we open Pandora’s box of existential types to solve some mind-bending type issues.

Video

Cloudflare Stream video ID: e71eae4d2f10d8e9b42d7357c5a8ddc8 Local file: video_196_async-composable-architecture-tasks.mp4 *(download with --video 196)*

References

Transcript

0:05

So, this is a pretty big missing part of the ergonomics story for the Composable Architecture. We need to figure out a way to use Effect.task in our reducers because there are a ton of benefits:

0:15

It makes for simpler dependency clients that can just use async instead of returning Effect values, which means that our dependencies don’t even need to depend on the Composable Architecture.

0:24

It makes for simpler live and mock instances of dependencies

0:29

It makes for simpler construction of effects in reducers, and we can chain multiple asynchronous tasks together by just awaiting one after another.

0:39

And finally it means we can even sometimes remove schedulers from our environment, especially if we don’t need to schedule time-based work.

0:45

But most importantly, we want to allow usage of Effect.task in our reducers in a way that does not affect tests. Testing is by far the most important feature of the Composable Architecture, and we try our hardest to never add a feature to the library that hurts testability. We should strive to be able to write fully deterministic tests that run immediately.

1:05

And Effect.task is really just the tip of the iceberg. There are a lot more ways we’d like to more deeply integrate the library with Swift’s concurrency tools, but let’s start with the problem of Effect.task not being usable in reducers.

1:17

The main problem with using async/await directly in the reducer is that all new asynchronous contexts are spun up when effects execute in tests, and we are forced to wait for small amounts of times for those tasks to finish and feed their data back into the system.

1:32

Let’s see how we can fix this. Testing async-received actions

1:36

What if we made a version of the receive method on TestStore that was asynchronous so that we could await it until the action we expect is received into the system. It could look something like this: store.send(.numberFactButtonTapped) { $0.isNumberFactRequestInFlight = true } await store.receive( .numberFactResponse(.failure(FactClient.Error())) ) { $0.isNumberFactRequestInFlight = false }

1:49

Where after we send the numberFactButtonTapped action we await until we receive the response.

1:55

This would alleviate the need to wait for time to pass because we can just let the system evolve however it wants, and once an action is delivered we will un-suspend and allow the test to continue.

2:05

We will still want to keep the non-async version of the receive method for those who cannot go all-in to Swift’s new concurrency tools just yet, but someday in the future we may be able to deprecate that method and only use the asynchronous version.

2:16

Let’s see what it takes to make this work.

2:18

Let’s first see how the current, synchronous receive method works so that we can understand what its asynchronous counterpart will look like. The signature of the method takes the action we expect to receive from an effect, along with a closure that allows us to mutate the current state into its final expected form after the action is received: public func receive( _ expectedAction: Action, _ updateExpectingResult: ((inout LocalState) throws -> Void)? = nil, file: StaticString = #file, line: UInt = #line ) { … }

2:37

The LocalState generic is a feature of the test store that is seldom used, but can be handy. Many do not know this, but TestStore s can be scoped just like regular Store s, and it allows you to focus your test on a smaller domain within a large domain. But for the purpose of our exploration we can just assume LocalState is the regular State of the store.

2:55

The method also takes a file and line number, but we don’t ever need to pass these in explicitly. Each has a default, and these parameters are only used for putting test failure messages on the lines that caused the test failure.

3:06

The test store keeps an array of received actions that have been received from effects, and so all the receive method really needs to do is check the first element of this array to see if it matches the action we passed in.

3:17

But, if the array is empty it means there are no received actions, and so that should be a test failure since we are saying that we received an action when nothing was actually received: guard !self.receivedActions.isEmpty else { XCTFail( """ Expected to receive an action, but received none. """, file: file, line: line ) return }

3:24

Once we know the receivedActions array is not empty we can pluck off the first element, which actually holds the action that was received as well as the state of the system at the moment after the action was received: let (receivedAction, state) = self.receivedActions.removeFirst()

3:34

We’ll see why we need that state in a moment.

3:36

Once we have the action we can check if it matches the expectation that the user passed into the receive method, and if it doesn’t we can create a test failure: if expectedAction != receivedAction { … }

3:44

This forces the user to prove they know exactly what action is being sent back into the system.

3:48

Next we pass the user’s updateExpectingResult mutating function to a helper that updates the test store’s current state in order to compute the state that the user thinks is the correct value. This makes assertions on whether the state the user expects matches the actual state, which is what was bundled with the action in the receivedActions element: do { try expectedStateShouldMatch( expected: &expectedState, actual: self.toLocalState(state), modify: updateExpectingResult, file: file, line: line ) } catch { XCTFail("Threw error: \(error)", file: file, line: line) } self.state = state

4:17

So, that’s the basics of the receive method.

4:20

The asynchronous version of receive needs to do basically the same thing as all of this, but rather than immediately failing if the receivedActions array is empty we will use the asynchronous context to wait around for an action to arrive so that we can then assert against it.

4:33

Let’s start by getting a signature in place that represents the asynchronous version of receive : public func receive( _ expectedAction: Action, _ updateExpectingResult: ((inout LocalState) throws -> Void)? = nil, file: StaticString = #file, line: UInt = #line ) async { }

4:41

This overload is identical to the other one except for the fact that it is marked as async .

4:47

The way Swift disambiguates which one is called is based one the surrounding context. If you are in a non-async context then Swift will pick the non-async overload, whereas if you are in a async context then Swift will pick the async overload and force you to await it.

5:01

To see this concretely, we can hop back over the tests and mark any test as async : func testNumberFact_HappyPath() async { … }

5:10

It compiles because Swift is now choosing the async overload of receive : await store.receive( .numberFactResponse(.success("1 is a good number Brent")) ) { … }

5:16

If you really want to call the synchronous overload from an asynchronous context, you must do a little dance to explicitly force a synchronous context in order to force Swift to pick the right one: _ = { store.receive(.factResponse(.success(""))) }()

5:37

So, we now have a stub in place for our new async receive , how can we implement it?

5:46

Remember that its functionality is basically the same as the synchronous receive , except instead of failing if receivedActions is empty we should use the asynchronous context to wait until the receivedActions array becomes non-empty. And then, once it is non-empty, we can invoke the synchronous overload of receive to perform all the logic we just stepped through above/below.

6:03

To handle the first step, that of waiting for receivedActions to become non-empty, we can literally start up an infinite loop that constantly checks the array, and once it is non-empty we can break out of the loop: while true { guard self.receivedActions.isEmpty else { break } }

6:21

This would certainly do the job, but there are a few improvements we can make.

6:25

First of all, this infinite loop is going to completely block whatever thread we are on until the guard condition is met allowing us to break out of it. This is generally a very bad thing to do, because Swift manages a small set of cooperative threads, and hands them out to tasks to do their job. In order to be a good citizen in this pool of threads we should minimize the amount of intensive work we do on the threads, which includes blocking or holding them up.

6:47

So, what we can do is sprinkle in a Task.yield in the while loop, which tells the Swift concurrency runtime that it can briefly suspend our current task in order to let other tasks do any work they need. while true { await Task.yield() guard self.receivedActions.isEmpty else { break } }

6:55

That alone makes us a better citizen in the thread pool and prevents us from hogging a single thread for too long.

6:59

Now that we have to wait until the receivedActions array becomes non-empty, we can finally call the synchronous receive overload in order for it to finish off the logic: public func receive( _ expectedAction: Action, _ updateExpectingResult: ((inout LocalState) throws -> Void)? = nil, file: StaticString = #file, line: UInt = #line ) async { while true { await Task.yield() guard self.receivedActions.isEmpty else { break } } { self.receive( expectedAction, updateExpectingResult, file: file, line: line ) }() }

7:16

That is all it takes to get the basics of this method into place.

7:20

We can now make use of this async receive rather than relying on waiting around for small amounts of time to pass and hoping everything works. For example, let’s hop back over to the testNumberFact_HappyPath test that we made async a moment ago.

7:26

If we run that test we will unfortunately see that we still have failures. In fact, now we have even more failures, including a few mentions something about the main thread: testNumberFact_HappyPath(): A store initialized on a non-main thread. … If a store is intended to be used on a background thread, create it via “Store.unchecked” to opt out of the main thread checker. The “Store” class is not thread-safe, and so all interactions with an instance of “Store” (including all of its scopes and derived view stores) must be done on the same thread.

7:39

The Store type is not thread-safe in the Composable Architecture, and by default it assumes that all interactions with it should take place on the main thread. This includes sending actions, either directly or via effects, and even initializing the store.

7:52

These errors are letting us know that the store was interacted with on a non-main thread, which can result in subtle bugs or crashes. Luckily the fix is easy. We can just mark the entire test as @MainActor to force each test case to run on the main thread: @MainActor class EffectsBasicsTests: XCTestCase { … }

8:15

Now when we run tests, they pass!

8:19

Further, we no longer need to provide a main queue scheduler because we aren’t using it in the reducer anymore: // store.environment.mainQueue = .immediate

8:27

And it still passes. We can even repeatedly run the test 1,000 times and it still passes with 100% accuracy: Test Suite 'Selected tests' passed. Executed 1000 tests, with 0 failures (0 unexpected) in 1.563 (1.849) seconds

8:53

While we’re at it, let’s improve our test for the unhappy path by marking it async , have the test store await to receive actions, and remove the immediate scheduler and explicit wait: func testNumberFact_UnhappyPath() async { … // store.environment.mainQueue = .immediate … // _ = XCTWaiter.wait(for: [.init()], timeout: 0.02) await store.receive( .numberFactResponse(.failure(FactClient.Failure())) ) { $0.isNumberFactRequestInFlight = false } }

9:13

We have now completely fixed the non-deterministic failures in this test which means we are now free to use Effect.task directly in our reducer if we want. This is a huge win.

9:22

And of course if you get some part of the expectation of the effect wrong, you will get a nice failure message: await store.receive( .numberFactResponse(.success("2 is a good number Brent")) ) { $0.isNumberFactRequestInFlight = false $0.numberFact = "2 is a good number Brent" } testNumberFact_HappyPath(): A state change does not match expectation: … EffectsBasicsState( count: 1, isNumberFactRequestInFlight: false, − numberFact: "2 is a good number Brent" + numberFact: "1 is a good number Brent" ) (Expected: −, Actual: +)

9:43

However, we’re not quite done with the async receive yet. What were to happen if in the test we tell the test store that we expect to receive an action, but no action is received? For example, let’s temporarily not execute the fact network request when the fact button is tapped: case .numberFactButtonTapped: return .none

10:00

When we run tests they hang forever and do not complete. This is happening because we are in an infinite loop that will never break since an action is never sent back into the system.

10:14

Previously, when everything was synchronous, we would just fail immediately if trying to receive an action while the receivedActions array was empty, but now in the asynchronous world we have to be open to the possibilities of a small amount of time passing before we receive an action.

10:23

The best way to handle this situation is to force breaking out of the infinite loop if some amount of time passes, like say one second. Since ideally all of our dependencies are controlled, including our dependency on schedulers and time, we shouldn’t have to wait a significant amount of time for effects to finish and feed their data back into the system. Waiting one second should be plenty of time: let start = DispatchTime.now().uptimeNanoseconds while true { await Task.yield() guard self.receivedActions.isEmpty, start.distance(to: DispatchTime.now().uptimeNanoseconds) < NSEC_PER_SEC else { break} }

11:14

We could even allow the user to pass in an explicit timeout, in case there is some code outside their control that takes some time to execute: public func receive( _ expectedAction: Action, timeout: UInt64 = NSEC_PER_SEC, _ updateExpectingResult: ((inout LocalState) throws -> Void)? = nil, file: StaticString = #file, line: UInt = #line ) async { let start = DispatchTime.now().uptimeNanoseconds while true { await Task.yield() guard self.receivedActions.isEmpty, start.distance(to: DispatchTime.now().uptimeNanoseconds) < timeout else { break} } }

11:32

Now when we run tests they no longer hang and we do get a proper test failure letting us know that an action was expected to be received, but none was received.

11:44

The tests do take a little longer than they should because we are waiting around for a bit to figure out whether or not an action is going to come through. However, we can speed this up a bit in certain situations.

11:56

If we try to receive an action and there are no inflight effects, then we know for sure we will never receive an action, no matter how long we wait, so we might as well short-circuit the wait: while !self.inFlightEffects.isEmpty { await Task.yield() guard self.receivedActions.isEmpty, start.distance(to: DispatchTime.now().uptimeNanoseconds) < timeout else { break } }

12:20

Now our test runs and fails immediately with no wait at all. Introducing TaskResult

12:34

So this is pretty incredible. With just one additional method added to the TestStore , whose implementation is only a handful of lines, we have unlocked the ability to use Effect.task in our reducers, which comes with tons of benefits, without affecting our ability to test our code in a deterministic and predictable manner. And we accomplished all of that without hurting the backwards compatibility of the library.

12:57

All existing code written against the Composable Architecture should compile and work exactly as it did before, but now if you decide to use Effect.task in your reducer you better make sure to use the new async API on the TestStore .

13:10

So, this is look great, but as we mentioned last episode, there is still some awkwardness to using Effect.task in the reducer, and that has to do with error handling. Let’s take a look at that.

13:22

Currently our FactClient interface is modeled as an async throws function: struct FactClient { var fetchAsync: (Int) async throws -> String … }

13:27

Let’s ignore that the other fetch endpoint even exists since eventually we would like to get rid of them.

13:35

Notice that fetchAsync uses throws , which makes sense because if we are going to leverage Swift’s async functionality for making asynchronous requests why wouldn’t we also use Swift’s throws functionality for error handling?

13:48

Well, the problem is that throws in Swift is untyped, which means we have no way of specifying what type of error is thrown, just that some error is thrown. The error is of type Error : var fetchAsync: (Int) async throws /*(Error)*/ -> String

14:02

But for all intents and purposes it might as well be Any : var fetchAsync: (Int) async throws /*(Any)*/ -> String

14:09

Now Swift takes the stance that for many cases this is completely fine. Often it is enough to just know that some error occurred without needing to know the particulars of the error. And when you need to know the particulars of the error you can try casting the error to a type you know about, and often that can work just fine.

14:25

The only reason that untyped throws gives us trouble in the Composable Architecture is because we often like to put errors into result types that go into a feature’s actions, and once you do that you prevent Swift from automatically synthesizing an Equatable conformance for your actions.

14:51

An Equatable conformance is very important for testing features in the Composable Architecture, so we would like to preserve it at all costs. And luckily for us, it’s possible for us to embrace async throws in our dependencies, and use try in our reducers, and still preserve equatability of action enums.

15:08

It may sound counterintuitive, but we actually need to remove some type-safety from Result in order to make error handling more ergonomic. After all, Swift’s error handling is not type-safe, and so we are only going to make things awkward for us to try to shoehorn type-safety into our actions.

15:23

We can do this by harkening back to the days when the Result type was first introduced to Swift, where it was hotly debated whether Result should be generic over its failure type, as it is today, or if it should be of the form: enum Result<Success> { case success(Success) case failure(Error) }

15:59

This is the shape of result we want to use because it’s all Swift’s throwing mechanism really allows for.

16:29

We of course don’t want to name it Result because that would conflict with Swift’s Result . Instead, we will call it TaskResult as this type is really only meant to be used in task contexts: public enum TaskResult<Success> { case success(Success) case failure(Error) }

16:40

Now, you may be wondering, “how does this help because this type still can’t be Equatable ?”

16:47

Well, that’s not entirely true. There is a little bit of Swift magic we can perform to make this type have a reasonable Equatable conformance as long as Success is Equatable : extension TaskResult: Equatable where Success: Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { <#code#> } } Let’s see how far we can get into implementing this before we run into trouble.

17:11

We can start by switching on both the lhs and rhs so that we can consider each combination of success and failure cases: extension TaskResult: Equatable where Success: Equatable { static func == (lhs: Self, rhs: Self) -> Bool { switch (lhs, rhs) { case (.success, .success): case (.failure, .failure): case (.success, .failure): case (.failure, .success): } } }

17:29

Three of four of these cases are straightforward to implement.

17:33

First, if the left and right side aren’t even in the same case that means they definitely can’t be equal: switch (lhs, rhs) { case (.success, .success): case (.failure, .failure): case (.success, .failure), (.failure, .success): return false }

17:47

And if both are in the success case then we can differ to the Equatable conformance on Success to implement that case: switch (lhs, rhs) { case let (.success(lhs), .success(rhs)): return lhs == rhs case (.failure, .failure): case (.success, .failure), (.failure, .success): return false }

18:01

Now we just have to figure out what to do with two failures: case let (.failure(lhs), .failure(rhs)):

18:05

We don’t know anything about these failures other than the fact that they conform to the Error protocol. An existential digression

18:12

So, that seems like a show stopping problem, but there’s something we can do to save this. It isn’t 100% foolproof, but we think it is good enough.

18:21

What we’d like to do is first check if lhs and rhs already conform to the Equatable protocol, and if so use that conformance to check for equality. And if the types are not equatable, then we will just say they are not equal.

18:36

This of course is not ideal. It means it’s possible to have two errors that for all intents and purposes are equal, but cannot be recognized as equal because they do not conform to Equatable . Luckily, all the errors that UIKit and Foundation vend are equatable, as far as we know, and it’s usually very easy to make custom error types equatable since they typically contain very simple data. We can even make the false negatives a little less annoying by providing detailed error messaging in test failures.

19:08

But first we need to figure out how can we check the equality of two values for which we have no type information. All we know is that they conform to Error , which for all intents and purposes might as well be Any .

19:22

This actually used to be quite tricky to accomplish in Swift, but now in Swift 5.7 it’s a lot more straightforward, though still a little tricky.

19:29

Let’s start simple. What would it take to implement a function that takes a fully type-erased value as an argument, and returns a boolean that answers the question of whether or not the value is equatable: func isEquatable(_ value: Any) -> Bool { <#???#> }

19:45

This used to be surprisingly tricky in Swift. If you did it in the way that seemed most obvious: func isEquatable(_ value: Any) -> Bool { value is Equatable }

20:02

Then you were met with the infamous error that Equatable can only be used as a generic constraint because it has “ Self requirements”.

20:10

However, in Swift 5.7, which is what we are using right now because we are using the Xcode 14 beta, the error is a lot simpler: error: use of protocol ‘Equatable’ as a type must be written ‘any Equatable’

20:19

This is just saying that it is no longer allowed to use bare protocols as types, but instead we have to prefix it with the any keyword: func isEquatable(_ value: Any) -> Bool { value is any Equatable }

20:28

And now somehow this compiles, and it even works as we expect: XCTAssertTrue(isEquatable(1)) XCTAssertTrue(isEquatable("Hello")) XCTAssertTrue(isEquatable(true)) XCTAssertFalse(isEquatable({ $0 + 1 })) XCTAssertFalse(isEquatable(1, 2)) XCTAssertFalse(isEquatable(())) XCTAssertFalse(isEquatable(VStack {}))

22:18

So, what is this any keyword all about?

22:22

It’s what is know as an “existential” type, which is a jargony word that you have probably seen thrown around a lot, especially recently since Swift has made great advances in the ergonomics of existentials. The reason Swift does not want you using protocols in a bare fashion when specifying a type is because protocols as types aren’t really the same as the concrete types we usually deal with.

22:47

When you say: let x: Int = 1

22:52

You are expressing that x is a variable whose value is from the type Int .

22:59

However, if you were to write something like: let y: Equatable = 1

23:04

Then you are not saying that y is a variable whose value is from the type Equatable . After all, Equatable is a protocol, not a concrete thing with values.

23:13

Instead, you are actually doing two things with such a statement. You are first singling out a particular type that conforms to Equatable , in this case Int , and then selecting a value from that type. That’s different enough from concrete types that it perhaps warrants its own name, which is “existential”, and it’s own syntax, which is the any prefix keyword. let y: any Equatable = 1

23:36

The term “existential” is borrowed from predicate logic in mathematics where one often wants to consider logical statements of the form “there exists an x such that P(x) is true,” written as: Statement ∃𝑥 𝐏(𝑥)

24:02

It allows you to make mathematical statements such as: Statement ∃𝑥 in the set of real numbers such that 𝑥×𝑥 > 0

24:13

Which is true because every real number except for 0 satisfies x*x > 0 . This existential statement doesn’t say anything about the value that satisfies the predicate. It is just a statement on the existence of there being a value that satisfies the predicate, but gives you no further information about the value. In that way it kind of hides information from you.

24:36

In fact, you can even think of protocols used in type positions, also known as existential types, as being a kind of “infinite” enum of all types that conform to the protocol.

24:50

So something like: let y: any Equatable = 1

24:54

…is really something like: let y: Bool | Int | String | [Int] | [[Int]] | … = 1

25:21

By saying that y is a value in this infinite enum we are saying that there exists a concrete type somewhere in these cases and y is a value of that type.

25:31

Even the Any type that has been in Swift from the very beginning can be thought of as this way. It’s a magical type that every type is a subtype of, and can be thought of as an infinite enum that has a case for every type ever thought of: let z: Any = Void | Bool | Int | String | [Void] | (Int) -> Bool | …

26:04

Notice that it contains more cases than any Equatable , such as Void and functions, which are not equatable.

26:19

This concept of an infinite enum is of course completely theoretical, and we could never actually get a handle on an infinite enum, such as switching over it. It is only a tool for us to intuitively understand what is going on. In fact, the new tools that Swift 5.7 introduces for dealing with existentials can be interpreted as allowing us to in some sense “process” the infinite enum.

26:43

For example, the fact that the is operator works with existentials: func isEquatable(_ value: Any) -> Bool { value is any Equatable }

26:48

…can be interpreted as searching the infinite enum of any Equatable to find the case that value is in. If it finds the case in there, it returns true , and otherwise false .

27:01

There’s another another tool Swift gives to get a handle on these theoretical infinite enums, and it’s similar to is . The as operator allows you to whittle down one infinite enum to another, “smaller” enum.

27:15

For example, suppose we defined a function that takes an Any existential, remembering that Any is really a kind of infinite enum: func existentialFun( _ value: Any /* Void | Int | String | …*/ ) { }

27:31

What if inside this function we wanted to remove all the non-equatable stuff from the infinite enum, such as Void , arrays of voids, functions, etc.? Well, we can use the as? operator to cast the Any existential to the any Equatable existential: func existentialFun(_ value: Any) { guard let value = value as? any Equatable else { return } }

27:56

After the guard , the value is of type any Equatable . We have essentially filtered one infinite enum down to a “smaller” infinite enum.

28:15

Just to be sure this is doing what we are saying, we can clearly see that value before the guard can only be thought of as an Any : func existentialFun(_ value: Any) { _ = value as Any _ = value as any Equatable guard let value = value as? any Equatable else { return } }

28:31

But after the guard it can be thought of as an any Equatable too: func existentialFun(_ value: Any) { _ = value as Any _ = value as any Equatable guard let value = value as? any Equatable else { return } _ = value as Any _ = value as any Equatable }

28:42

This is pretty cool. Most, if not all, of Swift’s fancy new existential type tools can be thought of as some kind of operation on infinite enums. We’ve already seen this with the is and as operator, but now let’s look at another.

28:55

There’s the concept of “opening” an existential. Where the is operator allowed checking if a case exists in an infinite enum and the as operator allowed filtering the infinite enum, the “open” operator allows finding the case of a type in the infinite enum and actually getting your hands on the type.

29:19

Now, there isn’t an actual open operator in Swift for doing this, instead it just kinda works implicitly. To open an existential you define a function with a generic that is constrained to the protocol you are interested in: func open<A: Equatable>(_ a: A) { }

29:33

Note that A is now a static, generic type. It is not an existential type. This means if we had two values of type A then we could invoke the == operator on them: func open<A: Equatable>(_ a: A) { a == a }

29:46

Which is not something we could do with any Equatable : value == value ‘==’ cannot be applied to two ‘any Equatable’ operands

29:46

And this isn’t possible because the compiler doesn’t know if the lefthand side and righthand side are the same equatable type. And so that’s why this “open” operator is so important, it allows you to get a handle to the actual underlying equatable type.

30:22

In fact, there is now a slightly shorter, less cryptic way of writing this function using some types in Swift 5.7: func open(_ a: some Equatable) { a == a }

30:33

A some Equatable type used as an argument is like introducing a generic without having to literally introduce a generic.

30:44

So, this all sounds good, but it all hinges on us being able to pass an existential to something that uses a generic. Turns out, Swift can do this automatically for us with no additional work on our part: func open(_ a: some Equatable) { a == a } func existentialFun(_ value: Any) { guard let value = value as? any Equatable else { return } open(value) }

31:18

It may not seem like much, but we have accomplished something amazing here. We went from a function that takes a completely opaque, untyped value and passed it through to a function that is statically typed and only works with Equatable types.

31:36

This is the final tool we need to in order to implement an equals function that works for two Any values. Let’s get a stub of a function into place: func equals(_ lhs: Any, _ rhs: Any) -> Bool { }

32:04

The first thing we want to do is cast these Any existentials to any Equatable existentials: func equals(_ lhs: Any, _ rhs: Any) -> Bool { guard let lhs = lhs as? any Equatable, let rhs = rhs as? any Equatable else { return false } }

32:28

Now that we have two existentials we can open them up to get their static, Equatable -conforming types. However, we cannot open both types simultaneously like this: func equals(_ lhs: Any, _ rhs: Any) -> Bool { func open( _ lhs: some Equatable, _ rhs: some Equatable ) -> Bool { lhs == rhs } guard let lhs = lhs as? any Equatable, let rhs = rhs as? any Equatable else { return false } return open(lhs, rhs) }

32:49

This can’t possibly work because the opened type of lhs may be different from rhs , like if we passed an integer and a string to equals . And the fact that we are using two some types in the open function also shows this since those are two completely unrelated generic types.

33:06

However, we can open just one of the existentials, leave the other closed, and then perhaps we can cast the right side to be the type of the left side: func open(_ lhs: some Equatable, _ rhs: Any) -> Bool { lhs == (rhs as? <#???#>) }

33:20

Well, we don’t have the type of lhs . Now, the some Equatable is like a generic, but you don’t actually have access to the generic. If you really do need your hands on the generic, you have to go back to the old style and use brackets to introduce a type parameter: func open<A: Equatable>(_ lhs: A, _ rhs: Any) -> Bool { lhs == (rhs as? A) }

33:59

Amazingly this compiles, and accomplishes what we want. It can take any two values of any type, check if they are equatable and of the same type, and if so, invoke their == operator.

34:39

We could even improve the ergonomics for the user by inserting a runtime warning if we see they are trying to check the equality of two non-equatable errors: guard let lhs = lhs as? any Equatable else { runtimeWarning( "Tried to compare a non-equatable error type: %@", ["\(type(of: lhs))"] ) return false }

35:23

We can give this a spin just to make sure it works: XCTAssertTrue(equals(1, 1)) XCTAssertTrue(equals("Hello", "Hello")) XCTAssertFalse(equals(true, false)) XCTAssertFalse(equals((), ())) A universal thought experiment

36:28

We finally have the missing puzzle piece to implement the Equatable conformance on TaskResult , but before we do that we have one last theoretical side quest.

36:39

If you have been watching Point-Free for any time at all you will know that we never like to give preferential treatment of structs over enums or enums of structs. Because the two are duals to each other, if there is a concept defined for one then we should strive to understand what the analogous concept looks like for the other.

36:56

But it looks like we have created an imbalance here. We just went deep into the concept of “infinite enums” and showed that they correspond to existential types. What then are “infinite tuples”?

37:07

Well, Swift has already kinda got “infinite tuples”, but they are called generics!

37:11

Whenever you write a generic function like so: func id<A>(_ a: A) -> A { return a }

37:15

You can think of A as being a kind of “infinite” tuple that holds every single type ever conceived: func id<A>( _ a: (Void, Bool, Int, String, …) ) -> (Void, Bool, Int, String, …) { return a }

37:37

To implement a generic function is to provide an implementation that works on every type in existence, hence it’s like writing an implementation on an infinite tuple of all types.

38:01

Another name of such a type is a “universal”. Where existentials allow you to express a value as an infinite enum, a universal allows you to express a value as an infinite tuple.

38:11

Swift doesn’t use the term “universal” anywhere, but it’s appropriate to use because it is the dual concept of “existential” in mathematics, just as enums and structs are duals. In math, a universal statement is a form of statement in predicate logic of the form “for all x, P(X) is true”: Statement ∀𝑥 𝐏(𝑥)

38:39

It allows you to make mathematical statements such as: Statement ∀𝑥 in the set of real numbers, 𝑥×𝑥≥0

38:47

Which is true, because indeed any real number squared is always going to be greater than or equal to zero. It is a universal statement because it claims that the predicate must be satisfied on all values, as opposed to existentials which claim there exists a value, but does not give any information on which value.

39:08

Now, Swift does not support constructing universal types directly like we can with existential types. Theoretically, the syntax could look something like this if we wanted to lean on generics brackets: let x: <A> A

39:24

And that could be seen as representing an infinite tuple of all types: let x: (Void, Bool, Int, …)

39:40

You might even be able to introduce type constraints: let x: <A: Equatable> A

39:48

And that would represent the infinite tuple of all equatable types: let x: (Bool, Int, …)

39:58

Heck, we could even make this theoretical syntax look more like any by introducing an all keyword: let x: all Equatable

40:09

And then maybe Swift could have a magical All type like it does for Any : // let x: <A> A let x: All

40:22

This is of course all very theoretical, but it’s instructive to think of types like this for intuition. For example, we now have a new way of thinking about opening existentials. Previously we were able to describe the is and as operator quite simply as either searching inside an infinite enum or filtering the cases of infinite enum. But then when we discussed opening existentials we kinda just waved our hands and said we find the type in the infinite enum and then somehow promote it to generic type, now also known as a universal type.

40:59

Well, we can now think of opening existentials as the process of turning an Any into an All : (Any) -> All

41:12

It allows us to turn a dynamic, nebulous type into a static, strict type.

41:17

However, in practice the process isn’t as simple as literally handing an Any to something and getting an All back. Instead, we had to define a little inside open function with a generic in order to get the universal type. So really, it’s more like this: (Any, (All) -> Void) -> Void That is, to open an existential you provide the Any you want to open and a handler that is given a universal All .

41:53

In fact, this is the exact shape that a hidden, not often used function in the standard library that can open existentials has. It’s literally called “_openExistential”, though it is underscored: public func _openExistential< ExistentialType, ContainedType, ResultType >( _ existential: ExistentialType, do body: (_ escapingClosure: ContainedType) throws -> ResultType ) rethrows -> ResultType {

42:07

The reason it is underscored is because it is quite tricky to use correctly, and the Swift team preferred to have better language-level support for opening existential rather than rely on this cryptic function.

42:19

But, if we strip away some of its noise we will see it has the exact shape that we are theorizing: _openExistential(E, do: (A) -> R) -> R

42:29

The R parameter is just the result that we compute after opening the existential. In our sketch above we just used Void , but this is more general. TaskResult equality and ergonomics

43:06

Phew!

43:07

OK, that was a huge side quest into existential types, and it’s probably not what you were expecting on an episode about adding concurrency to the Composable Architecture. But these are important concepts to know because they are super powerful when wielded correctly, and we like to show the “how” behind these concepts rather than just immediately giving the answer.

43:24

But we are now finally ready to finish implementing the Equatable conformance for TaskResult .

43:34

All we have to do is make the .failure case in the switch call out to the equals function to check for equality: case let (.failure(lhs), .failure(rhs)): return equals(lhs, rhs)

43:49

It’s worth reiterating that this isn’t a perfect solution, but we think it’s good enough. While it is true that you can get a false negative, that is two values that are technically equal but we are unable to determine that because they don’t literally conform to Equatable , in practice this isn’t usually a problem. Most errors vended by UIKit, Foundation and other Apple frameworks are all equatable, and for custom types it is usually quite easy to conform.

44:15

Now that we finally have a fully fleshed out TaskResult , let’s start using it and see how it cleans up a bunch of messiness. We can start by using a TaskResult in our action instead of a Result : enum Action: Equatable { case numberFactResponse(TaskResult<String>) … }

44:33

Already it’s simplifying actions.

44:35

And then rather trying to destructure different types of errors directly in the Effect.task we will just pass along the error to the action: return .task { [count = state.count] in do { return .numberFactResponse( .success(try await environment.fact.fetchAsync(count)) ) } catch { return .numberFactResponse(.failure(error)) } } And already that is simplifying reducers.

44:45

With those few changes everything is now compiling.

44:48

It’s worth noting that even though the error type is fully erased, we are still free to destructure this error into specific error types if we want. For example, suppose we want to handle URLError s differently from all other errors. All we need to do is add another case to our switch: case let .numberFactResponse(.failure(error as URLError)): // TODO: handle URL error state.isNumberFactRequestInFlight = false return .none case .numberFactResponse(.failure): // TODO: error handling state.isNumberFactRequestInFlight = false return .none

45:19

So we still have all the same capabilities from when we were using Result , it’s just less awkward now.

45:23

Tests are still compiling even though we made some changes to the domain, and they are even passing! That is because in the unhappy path tests we are using the concrete FactClient.Failure type for our errors, and it happens to be equatable.

45:46

However, if we introduced a little local error just for tests, and forgot to make it equatable, we will immediately see a test failure letting us know we need to make it equatable: struct SomeError: Error {} store.environment.fact.fetchAsync = { _ in throw SomeError() } … await store.receive(.numberFactResponse(.failure(SomeError()))) { $0.isNumberFactRequestInFlight = false } testNumberFact_UnhappyPath(): Tried to compare a non-equatable error type: SomeError

46:09

So, let’s make the error equatable: struct SomeError: Error, Equatable {}

46:17

And now tests are passing again.

46:19

We will also get a failure if there is a mismatch in the error type, like if our dependency throws a SomeError but our tests tries receiving a FactClient.Error : await store.receive( .numberFactResponse(.failure(FactClient.Error())) ) { … } testNumberFact_UnhappyPath(): Received unexpected action: … EffectsBasicsAction.numberFactResponse( − TaskResult.failure(FactClient.Error()) + TaskResult.failure(EffectsBasicsTests.SomeError()) ) (Expected: −, Received: +)

46:40

So, while it seemed like a bit of a bummer to lose out on some supposed type safety in our errors, in practice it’s not so bad. As long as our errors are equatable everything will work out as we expect, and if we forget to make the error equatable we will get a nice test failure message letting us know to do so.

47:00

Well, now the entire test suite is compiling, running and passing, and it passes pretty much instantly: Test Suite 'Selected tests' passed. Executed 3 tests, with 0 failures (0 unexpected) in 0.008 (0.009) seconds

47:07

There is also one small change we can make to our TaskResult to help clean up our reducers even more. As we can seen in the reducer, explicitly opening up a do/catch scope just to separate the happy and unhappy code paths only to turn right around and bundle them up into a TaskResult is a little annoying.

47:30

There is actually an initializer on Result that helps with this pattern. It takes a closure for performing work that can fail and the initializer will take care of wrapping up the result either in the successful or failed case: func doSomethingThatMayFail() throws -> Int { 42 } Result { try doSomethingThatMayFail() } // Result<Int, Error>

48:10

This is nice because it hides away the boilerplate of the do/catch.

48:14

However, currently this initializer does not allow you to perform any async work inside the closure: func doSomethingThatMayFail() async throws -> Int { 42 } await Result { try await doSomethingAsyncThatMayFail() } Cannot pass function of type ‘() async throws -> Int’ to parameter expecting synchronous function type

48:23

This is because Swift currently lacks the feature that would allow the initializer to be called synchronously for the times you do not perform asynchronous work in the closure.

48:43

To support this functionality Swift needs the concept of “ reasync ” just as it has rethrows , but sadly that feature has not been implemented yet, and so that is why Result does not yet have this initializer.

48:53

However, we don’t need to worry about reasync . The whole point we are even introducing TaskResult is specifically for asynchronous contexts, so we are OK with forcing it to be asynchronous always: enum TaskResult<Success> { case success(Success) case failure(Error) init( catching body: @Sendable () async throws -> Success ) async { do { self = .success(try await body()) } catch { self = .failure(error) } } }

49:49

This compiles, but while we’re in here, let’s get TaskResult in shape for the future of concurrency: enum TaskResult<Success: Sendable>: Sendable { case success(Success) case failure(Error) init( catching body: @Sendable () async throws -> Success ) async { do { self = .success(try await body()) } catch { self = .failure(error) } } } Note that we are going to force that Success be Sendable because these values are meant to travel across asynchronous boundaries. Further, the trailing closure of the initializer needs to be @Sendable because we want to restrict the types of closures you can use to just the ones that are safe to use in concurrent contexts. If we didn’t mark these things as sendable then we would eventually get compiler warnings in our reducers, which would eventually be errors in Swift 6.

50:15

Now we can greatly simplify our effects by wrapping our invocation of the dependency endpoint in a TaskResult , and then wrapping that result into an action: case .numberFactButtonTapped: state.isNumberFactRequestInFlight = true state.numberFact = nil return .task { [count = state.count] in await .numberFactResponse( TaskResult { try await environment.fact.fetchAsync(count) } ) }

50:54

Much better! This is even shorter than the Combine version which needed to chain on the .receive(on:) and .catchToEffect operators. Now it’s basically all on one line and we don’t need to chain on operators in order to massage the effect into the shape the reducer wants. Next time: streams

51:07

So things are looking pretty good. We are now free to use Swift’s new async/await features directly in the effects returned by reducers. This means we could execute multiple awaits to chain together multiple asynchronous operations, and we could even use async let and task groups to run a multiple units of work in parallel, gathering up their output to feed back into the reducer.

51:26

But there are still more types of asynchrony we use in everyday applications, such as time based asynchrony. What do we do if we want to delay for some time in our effects, or throttle or debounce the emissions of effects? Such operations are covered by the Combine framework, and in fact are kinda the whole point of Combine.

51:46

However, Swift now has tools that are built on the foundation of async/await that provide a lot of the same functionality that Combine gives us, except in some cases even simpler. Instead of things like the Publisher protocol in Combine for expressing streams of values over time, we now have the AsyncSequence protocol. And instead of things like the Scheduler protocol for expressing how work is scheduled in the future, we now have the Clock protocol. Even better, Apple also has a Swift package called “async algorithms” that has all types of interesting operators to use with async sequences, and we will even be able to make use of those in our application.

50:15

We’d like to be able to make use of these tools in the library while still maintaining testability. That will be possible, but it will require a bit more time since the tools are still beta and a little buggy as of the most recent Xcode beta.

52:34

So instead, we are going to first introduce new tools to make Combine’s API look more similar to the newest async tools, which will be handy for migrating existing applications to async/await, and then someday in the future we will be able to start using the newer tools, like clocks.

52:51

Let’s explore this by adding a new feature to our case study that makes use of time…next time! References Collection: Concurrency Brandon Williams & Stephen Celis Note Swift has many tools for concurrency, including threads, operation queues, dispatch queues, Combine and now first class tools built directly into the language. We start from the beginning to understand what the past tools excelled at and where they faultered in order to see why the new tools are so incredible. http://pointfree.co/collections/concurrency Downloads Sample code 0196-tca-concurrency-pt2 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .