Using map, flatMap and compactMap in Combine
Published on: February 3, 2020Oftentimes 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.