Configuring error types when using flatMap in Combine
Published on: September 14, 2020When you're using Combine for an app that has iOS 13 as its minimum deployment target, you have likely run into problems when you tried to apply a flatMap
to certain publishers in Xcode 12. To be specific, you have probably seen the following error message: flatMap(maxPublishers:_:) is only available in iOS 14.0 or newer
. When I first encountered this error I was somewhat stumped. Surely we had flatMap
in iOS 13 too!
If you've encountered this error and thought the same, let me reassure you. You're right. We had flatMap
in iOS 13 too. We just gained new flavors of flatMap
in iOS 14, and that's why you're seeing this error.
Before I explain more about the new flatMap
flavors that we gained in iOS 14, let's figure out the underlying error that causes flatMap(maxPublishers:_:) is only available in iOS 14.0 or newer
in your project.
Understanding how flatMap works with Error types
In Combine, every publisher has a defined Output
and Failure
type. For example, a DataTaskPublisher
has a tuple of Data
and URLResponse
as its Output
((data: Data, response: URLResponse)
) and URLError
as its Failure
.
When you want to apply a flatMap
to the output of a data task publisher like I do in the following code snippet, you'll run into a compiler error:
URLSession.shared.dataTaskPublisher(for: someURL)
.flatMap({ output -> AnyPublisher<Data, Error> in
})
If you've written code like this before, you'll probably consider the following compiler error to be normal:
Instance method
flatMap(maxPublishers:_:)
requires the typesURLSession.DataTaskPublisher.Failure
(akaURLError
) andError
be equivalent
I've already mentioned that a publisher has a single predefined error type. A data task uses URLError
. When you apply a flatMap
to a publisher, you can create a new publisher that has a different error type.
Since your flatMap
will only receive the output from an upstream publisher but not its Error
. Combine doesn't like it when your flatMap
returns a publisher with an error type that does not align with the upstream publisher. After all, errors from the upstream are sent directly to subscribers. And so are errors from the publisher created in your flatMap
. If these two errors aren't the same, then what kind of error does a subscriber receive?
To avoid this problem, we need to make sure that an upstream publisher and a publisher created in a flatMap
have the same Failure
. One way to do this is to apply mapError
to the upstream publisher to cast its errors to Error
:
URLSession.shared.dataTaskPublisher(for: someURL)
.mapError({ $0 as Error })
.flatMap({ output -> AnyPublisher<Data, Error> in
})
Since the publisher created in the flatMap
also has Error
as its Failure
, this code would compile just fine as long as we would return something from the flatMap
in the code above.
You can apply mapError
to any publisher that can fail, and you can always cast the error to Error
since in Combine, a publisher's Failure
must conform to Error
.
This means that if you're returning a publisher in your flatMap
that has a specific error, you can also apply mapError
to the publisher created in your flatMap
to make your errors line up:
URLSession.shared.dataTaskPublisher(for: someURL)
.mapError({ $0 as Error })
.flatMap({ [weak self] output -> AnyPublisher<Data, Error> in
// imagine that processOutput is a function that returns AnyPublisher<Data, ProcessingError>
return self?.processOutput(output)
.mapError({ $0 as Error })
.eraseToAnyPublisher()
})
Having to line up errors manually for your calls to flatMap
can be quite tedious but there's no way around it. Combine can not, and should not infer anything about how your errors should be handled and mapped. Instead, Combine wants you to think about how errors should be handled yourself. Doing this will ultimately lead to more robust code since the way errors propagate through your pipeline is very explicit.
So what's the deal with "flatMap(maxPublishers:_:) is only available in iOS 14.0 or newer" in projects that have iOS 13.0 as their minimum target? What changed?
Fixing "flatMap(maxPublishers:_:)
is only available in iOS 14.0 or newer"
Everything you've read up until now applies to Combine on iOS 13 and iOS 14 equally. However, flatMap
is slightly more convenient for iOS 14 than it is on iOS 13. Imagine the following code:
let strings = ["https://donnywals.com", "https://practicalcombine.com"]
strings.publisher
.map({ url in URL(string: url)! })
The publisher that I created in this code is a publisher that has URL
as its Output
, and Never
as its Failure
. Now let's add a flatMap
:
let strings = ["https://donnywals.com", "https://practicalcombine.com"]
strings.publisher
.map({ url in URL(string: url)! })
.flatMap({ url in
return URLSession.shared.dataTaskPublisher(for: url)
})
If you're using Xcode 12, this code will result in the flatMap(maxPublishers:_:) is only available in iOS 14.0 or newer
compiler error. While this might seem strange, the underlying reason is the following. When you look at the upstream failure type of Never
, and the data task failure which is URLError
you'll notice that they don't match up. This is a problem since we had a publisher that never fails, and we're turning it into a publisher that emits URLError
.
In iOS 14, Combine will automatically take the upstream publisher and turn it into a publisher that can fail with, in this case, a URLError
.
This is fine because there's no confusion about what the error should be. We used to have no error at all, and now we have a URLError
.
On iOS 13, Combine did not infer this. We had to explicitly tell Combine that we want the upstream publisher to have URLError
(or a different error) as its failure type by calling setFailureType(to:)
on it as follows:
let strings = ["https://donnywals.com", "https://practicalcombine.com"]
strings.publisher
.map({ url in URL(string: url)! })
.setFailureType(to: URLError.self) // this is required for iOS 13
.flatMap({ url in
return URLSession.shared.dataTaskPublisher(for: url)
})
This explains why the compiler error said that flatMap(maxPublishers:_:)
is only available on iOS 14.0 or newer.
It doesn't mean that flatMap
isn't available on iOS 13.0, it just means that the version of flatMap
that can be used on publishers the have Never
as their output and automatically assigns the correct failure type if the flatMap
returns a publisher that has something other than Never
as its failure type is only available on iOS 14.0 or newer.
If you don't fix the error and you use cmd+click on flatMap
and then jump to definition you'd find the following definition:
@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *)
extension Publisher where Self.Failure == Never {
/// Transforms all elements from an upstream publisher into a new publisher up to a maximum number of publishers you specify.
///
/// - Parameters:
/// - maxPublishers: Specifies the maximum number of concurrent publisher subscriptions, or ``Combine/Subscribers/Demand/unlimited`` if unspecified.
/// - transform: A closure that takes an element as a parameter and returns a publisher that produces elements of that type.
/// - Returns: A publisher that transforms elements from an upstream publisher into a publisher of that element’s type.
public func flatMap<P>(maxPublishers: Subscribers.Demand = .unlimited, _ transform: @escaping (Self.Output) -> P) -> Publishers.FlatMap<P, Publishers.SetFailureType<Self, P.Failure>> where P : Publisher
}
This extension is where the new flavor of flatMap
is defined. Notice that it's only available on publishers that have Never
as their Failure
type. This aligns with what you saw earlier.
Another flavor of flatMap
that was added in iOS 14 is similar but works the other way around. When you have a publisher that can fail and you create a publisher that has Never
as its Failure
in your flatMap
, iOS 14 will now automatically keep the Failure
for the resulting publisher equal to the upstream's failure.
Let's look at an example:
URLSession.shared.dataTaskPublisher(for: someURL)
.flatMap({ _ in
return Just(10)
})
While this example is pretty useless on its own, it demonstrates the point of the second flatMap
flavor really well.
The publisher created in this code snippet has URLError
as its Failure
and Int
as its Output
. Since the publisher created in the flatMap
can't fail, Combine knows that the only error that might need to be sent to a subscriber is URLError
. It also knows that any output from the upstream is transformed into a publisher that emits Int
, and that publisher never fails.
If you have a construct similar to the above and want this to work with iOS 13.0, you need to use setFailureType(to:)
inside the flatMap
:
URLSession.shared.dataTaskPublisher(for: someURL)
.flatMap({ _ -> AnyPublisher<Int, URLError> in
return Just(10)
.setFailureType(to: URLError.self) // this is required for iOS 13
.eraseToAnyPublisher()
})
The code above ensures that the publisher created in the flatMap
has the same error as the upstream error.
In Summary
In this week's post, you learned how you can resolve the very confusing flatMap(maxPublishers:_:) is only available in iOS 14.0 or newer
error in your iOS 13.0+ projects in Xcode 12.0.
You saw that Combine's flatMap
requires the Failure
types of the upstream publisher that you're flatMapping over and the new publisher's to match. You learned that you can use setFailureType(to:)
and mapError(_:)
to ensure that your failure types match up.
I also showed you that iOS 14.0 introduces new flavors of flatMap
that are used when either the upstream publisher or the publisher created in the flatMap
has Never
as its Failure
because Combine can infer what errors a subscriber should receive in these scenarios.
Unfortunately, these overloads aren't available on iOS 13.0 which means that you'll need to manually align error types if your project is iOS 13.0+ using setFailureType(to:)
and mapError(_:)
. Even if your upstream or flatMapped publishers have Never
as their failure type.
If you have any questions or feedback for me about this post, feel free to reach out on Twitter.