In this episode of Combine Essentials, we take a look at two of Combine's most commonly used operators, map
and compactMap
.
Transforming Elements with Combine's Map Operator
As the name suggests, the map
operator maps or transforms the elements a publisher emits. Let's take a look at an example. We create a data task publisher by invoking the dataTaskPublisher(for:)
method on the shared URLSession
singleton. The publisher dataTaskPublisher(for:)
returns emits a tuple containing two values, a Data
object and a URLResponse
object.
private func request<T: Decodable>(_ endpoint: Endpoint) -> AnyPublisher<T, Error> {
let request = URLRequest(url: endpoint.url)
return URLSession.shared.dataTaskPublisher(for: request)
.decode(
type: T.self,
decoder: JSONDecoder()
)
.mapError { error in
switch error {
case is URLError:
return API.Error.requestFailed
case is DecodingError:
return API.Error.invalidResponse
default:
return API.Error.unknown
}
}
.eraseToAnyPublisher()
}
Notice that we apply the decode
operator to decode the Data
object. This isn't possible as the decode
operator expects the Output
type of the upstream publisher to be Data
.
We can resolve this by applying the map
operator to the data task publisher. The map
operator maps or transforms the elements it receives from its upstream publisher. In this example, it transforms tuples that consist of a Data
object and a URLResponse
object. The closure that is passed to the map
operator returns the Data
object. It's as simple as that.
private func request<T: Decodable>(_ endpoint: Endpoint) -> AnyPublisher<T, Error> {
let request = URLRequest(url: endpoint.url)
return URLSession.shared.dataTaskPublisher(for: request)
.map({ (data: Data, response: URLResponse) in
data
})
.decode(
type: T.self,
decoder: JSONDecoder()
)
.mapError { error in
switch error {
case is URLError:
return API.Error.requestFailed
case is DecodingError:
return API.Error.invalidResponse
default:
return API.Error.unknown
}
}
.eraseToAnyPublisher()
}
We can write this more succinctly by using shorthand argument names.
private func request<T: Decodable>(_ endpoint: Endpoint) -> AnyPublisher<T, Error> {
let request = URLRequest(url: endpoint.url)
return URLSession.shared.dataTaskPublisher(for: request)
.map { $0.data }
.decode(
type: T.self,
decoder: JSONDecoder()
)
.mapError { error in
switch error {
case is URLError:
return API.Error.requestFailed
case is DecodingError:
return API.Error.invalidResponse
default:
return API.Error.unknown
}
}
.eraseToAnyPublisher()
}
It is also possible to pass a key path to the map
operator.
private func request<T: Decodable>(_ endpoint: Endpoint) -> AnyPublisher<T, Error> {
let request = URLRequest(url: endpoint.url)
return URLSession.shared.dataTaskPublisher(for: request)
.map(\.data)
.decode(
type: T.self,
decoder: JSONDecoder()
)
.mapError { error in
switch error {
case is URLError:
return API.Error.requestFailed
case is DecodingError:
return API.Error.invalidResponse
default:
return API.Error.unknown
}
}
.eraseToAnyPublisher()
}
In short, the map
operator applies a transformation to the elements it receives from its upstream publisher.
Transforming Elements with Combine's CompactMap Operator
The compactMap
operator works similarly, but there is an important twist. Take a look at the following example. We declare a private, variable property, currentSelection
, of type Int?
and apply the Published
property wrapper to it.
@Published private var currentSelection: Int?
We want to expose a publisher that emits the current selection, but it should exclude nil
values. You may think that we can apply the filter
operator and that is an option. Let's try it out.
@Published private var currentSelection: Int?
var currentSelectionPublisher: AnyPublisher<Int?, Never> {
$currentSelection
.filter { $0 != nil }
.eraseToAnyPublisher()
}
This looks fine, but there is a problem. The Output
type of currentSelectionPublisher
is Int?
, an optional type. This is odd since we applied the filter
operator to filter out nil
values. The filter
operator excludes elements of its upstream publisher, but it doesn't change the Output
type of its upstream publisher.
The compactMap
operator is a better option in this scenario. It transforms the elements of its upstream publisher and excludes nil
values. Take a look at the updated example.
@Published private var currentSelection: Int?
var currentSelectionPublisher: AnyPublisher<Int, Never> {
$currentSelection
.compactMap { $0 }
.eraseToAnyPublisher()
}
Notice that the Output
type of currentSelectionPublisher
is Int
, not Int?
. The compactMap
operator transforms the elements of its upstream publisher and excludes nil
values. Even though the compactMap
operator doesn't transform the elements of its upstream publisher in this example, it is a common use case for the compactMap
operator. Why is that? The compactMap
operator is often used to change the Output
type of its upstream publisher from an optional type to a non-optional type. The above example illustrates this.
Let's take a look at another example in which the compactMap
operator does transform the elements of its upstream publisher. We define an enum, TabBarItem
, that defines a case for each tab bar item of the application's tab bar. The raw values for TabBarItem
are defined to be of type Int
.
enum TabBarItem: Int {
// MARK: - Cases
case news
case categories
case settings
case profile
}
Let's say we want the currentSelectionPublisher
to transform the elements of currentSelection
to TabBarItem
objects. The map
operator can handle this task, but there is a drawback to this approach. Can you spot the problem?
@Published private var currentSelection: Int?
var currentSelectionPublisher: AnyPublisher<TabBarItem?, Never> {
$currentSelection
.map {
guard let rawValue = $0 else {
return nil
}
return TabBarItem(rawValue: rawValue)
}
.eraseToAnyPublisher()
}
The Output
type of currentSelectionPublisher
is TabBarItem?
, an optional type. This isn't a major issue, but it is inconvenient. We can get rid of this inconvenience by applying the compactMap
operator instead. This is what that looks like.
@Published private var currentSelection: Int?
var currentSelectionPublisher: AnyPublisher<TabBarItem, Never> {
$currentSelection
.compactMap {
guard let rawValue = $0 else {
return nil
}
return TabBarItem(rawValue: rawValue)
}
.eraseToAnyPublisher()
}
Notice that the closure we pass to the compactMap
operator is identical to the closure we passed to the map
operator. The only change is the name of the operator. If the closure that is passed to the compactMap
operator returns nil
for an element, then that element isn't published by the publisher the compactMap
operator returns. That is why the Output
type of currentSelectionPublisher
is TabBarItem
, not TabBarItem?
.
We can simplify the implementation by applying the compactMap
operator twice. The first implementation is a bit more performant because every operator you apply to a publisher comes with a bit of overhead. That said, the implementation in which we apply the compactMap
operator twice is more concise and easier to read.
@Published private var currentSelection: Int?
var currentSelectionPublisher: AnyPublisher<TabBarItem, Never> {
$currentSelection
.compactMap { $0 }
.compactMap { TabBarItem(rawValue: $0) }
.eraseToAnyPublisher()
}
What's Next?
The map
and compactMap
operators are some of the most commonly used operators. They are easy to use and can be helpful to make your code easier to read and understand.