Configuring error types when using flatMap in Combine

Published by donnywals on

When 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 types URLSession.DataTaskPublisher.Failure (aka URLError) and Error 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.


Receive weekly updates about my posts

Categories: Combine