How to Use Combine's Map and CompactMap Operators

Combine Essentials

How to Use Combine's Map and CompactMap Operators

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.

How to Use Combine's Map and CompactMap Operators

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.