Using map, flatMap and compactMap in Combine

Published on: February 3, 2020

Oftentimes when you're working with Combine, you'll have publishers that produce a certain output. Sometimes this output is exactly what you need, but often the values that are output by a publisher need to be transformed or manipulated somehow before they are useful to their subscribers. The ability to do this is a huge part of what Combine is, what makes it so powerful, and Functional Reactive Programming (FRP) in general.

In this week's post, I will show you several common operators that you can use to transform the output from your publishers and make them more useful.

If you've been following along with my series on Combine, this article should be somewhat of a cherry on top of the knowledge you have already gained. You know how publishers and subscribers work, you know how to use Combine in a basic scenario and you know how to subscribe to publishers and publish your own values. Once you understand how you can use all this together with Combine's powerful operators, there's nothing stopping you from integrating Combine effectively in your apps.

Transforming a publisher's output with map

In Combine, publishers emit values over time. For example, we can create a very basic publisher that outputs integers as follows:

let intPublisher = [1, 2, 3].publisher

Or we can create a somewhat more elaborate publisher using a CurrentValueSubject:

let intSubject = CurrentValueSubject<Int, Never>(1)

Both of these examples can be subscribed to using Combine's sink method, and both will send integer values to the sink's receiveValue closure. Imagine that you want to display these integers on a label. You could write something like the following:

intSubject.sink(receiveValue: { int in
  myLabel.text = "Number \(int)"
})

There's nothing inherently wrong with the code above, but last week I showed that you can use the assign subscriber to update a UI element directly with the output of a publisher. That won't work if the publisher outputs integers and we want to assign its output to a label's text. To be able to use the intSubject as a direct driver for myLabel.text we need to transform its output so it becomes a string. We can do this using map:

intSubject
  .map { int in "Number: \(int)"}
  .assign(to: \.text, on: myLabel)

The preceding code is arguably a lot more readable. Instead of transforming the int into a string, and assigning the string to the label's text, we now have two distinct steps in our publisher chain. First, transform the value, then assign it to the label's text. Note that map in Combine is a lot like the map you may have used on Array or Set before.

Transforming values with compactMap

In addition to a simple map, you can also use compactMap to transform incoming values, but only publish them down to the subscriber if the result is not nil. Let's look at an example:

let optionalPublisher = [1, 2, nil, 3, nil, 4, nil, 5].publisher
  .compactMap { $0 }
  .sink(receiveValue: { int in
    print("Received \(int)")
  })

This code has the following output:

Received 1
Received 2
Received 3
Received 4
Received 5

Using compactMap in Combine has the same effect as it has on normal arrays. Non-nil values are kept while nil values are simply discarded.

This might lead you to wonder if combine also has a flatMap operator. It does. But it's slightly more complicated to grasp than the flatMap that you're used to.

Transforming values with flatMap

When you flatMap over an array, you take an array that contains other arrays, and you flatten it. Which means that an array that looks like this:

[[1, 2], [3, 4]]

Would be flatMapped into the following:

[1, 2, 3, 4]

Combine's map operations don't operate on arrays. They operate on publishers. This means that when you map over a publisher you transform its published values one by one. Using compactMap leads to the omission of nil from the published values. If publishers in Combine are analogous to collections when using map and compactMap, then publishers that we can flatten nested publishers with flatMap. Let's look at an example:

[1, 2, 3].publisher.flatMap({ int in
  return (0..<int).publisher
  }).sink(receiveCompletion: { _ in }, receiveValue: { value in
    print("value: \(value)")
  })

The preceding example takes a list of publishers and transforms each emitted value into another publisher. When you run this code, you'll see the following output:

value: 0
value: 0
value: 1
value: 0
value: 1
value: 2

When you use flatMap in a scenario like this, all nested publishers are squashed and converted to a single publisher that outputs the values from all nested publishers, making it look like a single publisher. The example I just showed you isn't particularly useful on its own, but we'll use flatMap some more in the next section when we manipulate how often a publisher outputs values.

It's also possible to limit the number of "active" publishers that you flatMap over. Since publishers emit values over time, they are not necessarily completed immediately like they are in the preceding code. If this is the case, you could accumulate quite some publishers over time as values keep coming in, and as they continue to be transformed into new publishers. Sometimes this is okay, other times, you only want to have a certain amount of active publishers. If this is something you need in your app, you can use flatMap(maxPublishers:) instead of the normal flatMap. Using flatMap(maxPublishers:) makes sure that you only have a fixed number of publishers active. Once one of the publishers created by flatMap completes, the source publisher is asked for the next value which will then also be mapped into a publisher. Note that flatMap does not drop earlier, active publishers. Instead, the publisher will wait for active publishers to finish before creating new ones. The following code shows an example of flatMap(maxPublishers:) in use.

aPublisherThatEmitsURLs
  .flatMap(maxPublishers: .max(1)) { url in
    return URLSession.shared.dataTaskPublisher(for: url)
  }

The preceding code shows an example where a publisher that emits URLs over time and transforms each emitted URL into a data task publisher. Because maxPublishers is set to .max(1), only one data task publisher can be active at a time. The publisher can choose whether it drops or accumulates generated URLs while flatMap isn't ready to receive them yet. A similar effect can be achieved using map and the switchToLatest operator, except this operator ditches the older publishers in favor of the latest one.

aPublisherThatEmitsURLs
  .map { url in
    return URLSession.shared.dataTaskPublisher(for: url)
  }
  .switchToLatest()

The map in the preceding code transforms URLs into data task publishers, and by applying switchToLatest to the output of this map, any subscribers will only receive values emitted by the lastest publisher that was output by map. This means if aPublisherThatEmitsURLs would emit several URLs in a row, we'd only receive the result of the last emitted URL.

In summary

In today's post, I showed you how you can apply transformations to the output of a certain publisher to make it better suited for your needs. You saw how you can transform individual values into new values, and how you can transform incoming values to a new publisher and flatten the output with flatMap or switchToLatest.

If you want to learn more about Combine, make sure to check out the category page for my Combine series. If you have any questions about Combine, or if you have feedback for me make sure to reach out to me on Twitter.

Categories

Combine

Subscribe to my newsletter