Extending Combine with convenience APIs
Discover page available: CombineApple’s Combine framework enables us to model our asynchronous code as reactive pipelines that each consist of a series of separate operations. Those pipelines can then be observed, transformed, and combined in various ways — and since Combine makes heavy use of Swift’s advanced generics capabilities, that can all be done with a high degree of type safety and compile time validation.
That strong type information can also be incredibly useful when extending Combine with convenience APIs, as it lets us create custom transforms and utilities that are tailored-made for specific kinds of output. This week, let’s take a look at a few ways to do just that, and how doing so can help us eliminate common sources of boilerplate when implementing things like networking and data validation.
Inserting default arguments
Although Combine ships with a quite comprehensive suite of built-in transforms (typically referred to as “operators”), sometimes we might want to create custom variants of those APIs that are tailored to the way they’re being used within a given app. Doing so typically involves extending Combine’s Publisher
protocol, and by using that protocol’s Output
and Failure
types, we can then choose exactly what kinds of publishers that we want to add our new APIs to.
For example, the standard decode
operator can be used to transform a publisher that emits Data
values into one that publishes instances of a Decodable
Swift type. However, that operator requires us to always manually pass which type that we’re looking to decode, as well as the decoder that we wish to use — which makes perfect sense given that the built-in operators aim to be as general-purpose as possible — but within our own code base, we could instead create a custom version of that operator that automatically inserts default values for those two arguments, like this:
extension Publisher where Output == Data {
func decode<T: Decodable>(
as type: T.Type = T.self,
using decoder: JSONDecoder = .init()
) -> Publishers.Decode<Self, T, JSONDecoder> {
decode(type: type, decoder: decoder)
}
}
Note how we’re returning an instance of Publishers.Decode
from the above convenience API, rather than using the type-erased AnyPublisher
type, which isn’t needed in this case since we have a known, concrete type that can be returned as-is.
Another option would be to replace the above call to .init()
with any statically shared JSONDecoder
instance that we’re using across our app. For example, if the web APIs that we’re downloading JSON from all use snake_case
-based keys, then we might instead want to use the following snakeCaseConverting
instance as our default decoder
argument:
extension JSONDecoder {
static let snakeCaseConverting: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}()
}
extension Publisher where Output == Data {
func decode<T: Decodable>(
as type: T.Type = T.self,
using decoder: JSONDecoder = .snakeCaseConverting
) -> Publishers.Decode<Self, T, JSONDecoder> {
decode(type: type, decoder: decoder)
}
}
With either of the above two extensions in place, we can now simply use .decode()
to decode JSON data whenever the compiler is able to infer our desired output type, for example in situations like this:
class ConfigModelController: ObservableObject {
@Published private(set) var config: Config
private let urlSession: URLSession
...
func update() {
// Here the compiler is able to infer which type that we’re
// looking to decode our JSON into, based on the property
// that we’re assigning our pipeline’s output to:
urlSession.dataTaskPublisher(for: .config)
.map(\.data)
.decode()
.replaceError(with: .default)
.receive(on: DispatchQueue.main)
.assign(to: &$config)
}
}
Our custom decode
operator is also really useful when we do have to manually specify our desired output type, as we can now simply do so like this:
cancellable = URLSession.shared
.dataTaskPublisher(for: .allItems)
.map(\.data)
.decode(as: NetworkResponse.self)
.sink { completion in
// Handle completion
...
} receiveValue: { response in
// Handle response
...
}
To take things even further, we could also create abstractions that’d let us avoid having to manually map each URLSession
publisher to Data
, and to always unpack the contents of the resulting NetworkResponse
type — just like we did in “Creating generic networking APIs in Swift”.
Data validation
Next, let’s take a look at how we can also extend Combine with dedicated APIs for performing data validation. As an example, let’s say that we wanted to verify that each NetworkResponse
instance loaded within the previous example contains at least one Item
. To make that happen, we could add the following API, which uses the tryMap
operator to enable us to use errors as control flow when validating a given publisher’s Output
values:
extension Publisher {
func validate(
using validator: @escaping (Output) throws -> Void
) -> Publishers.TryMap<Self, Output> {
tryMap { output in
try validator(output)
return output
}
}
}
We could then simply insert a call to the above validate
operator within our previous Combine pipeline, and throw an error in case our items
array turned out to be empty — like this:
cancellable = URLSession.shared
.dataTaskPublisher(for: .allItems)
.map(\.data)
.decode(as: NetworkResponse.self)
.validate { response in
guard !response.items.isEmpty else {
throw NetworkResponse.Error.missingItems
}
}
.sink { completion in
// Handle completion
...
} receiveValue: { response in
// Handle response
...
}
Another very common way to validate data in Swift is by unwrapping optionals, and although Combine’s built-in compactMap
operator does exactly that, using that operator makes a given pipeline ignore all nil
values, rather than throwing an error if a required value turned out to be missing. So if we instead want errors to be thrown in those situations, then we could once again make that happen by building a custom operator using tryMap
:
extension Publisher {
func unwrap<T>(
orThrow error: @escaping @autoclosure () -> Failure
) -> Publishers.TryMap<Self, T> where Output == Optional<T> {
tryMap { output in
switch output {
case .some(let value):
return value
case nil:
throw error()
}
}
}
}
Above we’re using the @autoclosure
attribute to automatically convert each error
argument into a closure, which in turn prevents those expressions from being evaluated unless they’re actually needed. This pattern is also used within the standard library to implement things like asserts. To learn more, check out “Under the hood of assertions in Swift”.
To take a look at what our new unwrap
operator looks like in action, let’s say that we’re loading another collection of Item
values from a recentItems
endpoint, only this time we’re only interested in the first element — which we can now easily unwrap like this:
cancellable = URLSession.shared
.dataTaskPublisher(for: .recentItems)
.map(\.data)
.decode(as: NetworkResponse.self)
.map(\.items.first)
.unwrap(orThrow: NetworkResponse.Error.missingItems)
.sink { completion in
// Handle completion
...
} receiveValue: { response in
// Handle response
...
}
Really nice! One thing that’s important to point out, though, is that a Combine pipeline is always completed when an error is encountered (unless that error is caught using operators like catch
or replaceError
), meaning that we should only use the above two data validation operators when that’s the kind of behavior that we’re looking for.
Result conversions
One of the most common ways to handle the output of a given Combine pipeline is by using the sink
operator along with two closures — one for handling each output value, and one for handling completion events.
However, handling successful output values and completions (such as errors) separately can sometimes be a bit inconvenient, and in those kinds of situations, emitting a single Result
value could be a great alternative — so let’s also introduce a custom operator for performing that kind of conversion:
extension Publisher {
func convertToResult() -> AnyPublisher<Result<Output, Failure>, Never> {
self.map(Result.success)
.catch { Just(.failure($0)) }
.eraseToAnyPublisher()
}
}
Our new convertToResult
operator could become especially useful when using Combine to build view models for SwiftUI views. For example, here we’ve now moved our previous item loading pipeline into an ItemListViewModel
, which uses our new operator to be able to easily switch
on the Result
of each loading operation:
class ItemListViewModel: ObservableObject {
@Published private(set) var items = [Item]()
@Published private(set) var error: Error?
private let urlSession: URLSession
private var cancellable: AnyCancellable?
...
func load() {
cancellable = urlSession
.dataTaskPublisher(for: .allItems)
.map(\.data)
.decode(as: NetworkResponse.self)
.validate { response in
guard !response.items.isEmpty else {
throw NetworkResponse.Error.missingItems
}
}
.map(\.items)
.convertToResult()
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
switch result {
case .success(let items):
self?.items = items
self?.error = nil
case .failure(let error):
self?.items = []
self?.error = error
}
}
}
}
To take things further, if we update the above view model to instead use the LoadableObject
protocol and LoadingState
enum from “Handling loading states within SwiftUI views”, then we could make the above implementation much simpler. Let’s start by also adding a conversion operator that emits LoadingState
values, rather than using the built-in Result
type:
extension Publisher {
func convertToLoadingState() -> AnyPublisher<LoadingState<Output>, Never> {
self.map(LoadingState.loaded)
.catch { Just(.failed($0)) }
.eraseToAnyPublisher()
}
}
With the above extension in place, we could then simply load and assign our view model’s state like this:
class ItemListViewModel: LoadableObject {
@Published private(set) var state = LoadingState<[Item]>.idle
...
func load() {
guard !state.isLoading else {
return
}
state = .loading
urlSession
.dataTaskPublisher(for: .allItems)
.map(\.data)
.decode(as: NetworkResponse.self)
.validate { response in
guard !response.items.isEmpty else {
throw NetworkResponse.Error.missingItems
}
}
.map(\.items)
.receive(on: DispatchQueue.main)
.convertToLoadingState()
.assign(to: &$state)
}
}
Although it might be easy to dismiss the above changes as being just “syntactic sugar”, reducing the amount of code needed to perform our most common types of operations can often result in a substantial boost in productivity, and can also help us avoid repeating bugs and mistakes by utilizing shared, fully tested implementations.
Type-erased constant publishers
Like we took a look at in “Publishing constant values using Combine”, sometimes we might want to create publishers that emit just a single value or error, which can be done using either Just
or Fail
. However, when using those publishers, we also typically have to normalize them using operators like setFailureType
and eraseToAnyPublisher
— for example like this:
class SearchResultsLoader {
private let urlSession: URLSession
private let cache: Cache<String, [Item]>
...
func searchForItems(
matching query: String
) -> AnyPublisher<[Item], SearchError> {
guard !query.isEmpty else {
return Fail(error: SearchError.emptyQuery)
.eraseToAnyPublisher()
}
if let cachedItems = cache.value(forKey: query) {
return Just(cachedItems)
.setFailureType(to: SearchError.self)
.eraseToAnyPublisher()
}
return urlSession
.dataTaskPublisher(for: .search(for: query))
.map(\.data)
.decode(as: NetworkResponse.self)
.mapError(SearchError.requestFailed)
.map(\.items)
.handleEvents(receiveOutput: { [cache] items in
cache.insert(items, forKey: query)
})
.eraseToAnyPublisher()
}
}
Again, our current implementation doesn’t have any huge problems, but if we’re using the above kind of pattern in multiple places throughout our app, then implementing a set of convenience APIs for it could definitely be worthwhile.
This time, let’s extend the AnyPublisher
type with two static methods — one for creating type-erased Just
publishers, and one for creating Fail
publishers:
extension AnyPublisher {
static func just(_ output: Output) -> Self {
Just(output)
.setFailureType(to: Failure.self)
.eraseToAnyPublisher()
}
static func fail(with error: Failure) -> Self {
Fail(error: error).eraseToAnyPublisher()
}
}
With the above two methods in place, we’ll now be able to use Swift’s “dot syntax” to create constant publishers with just a single line of code — like this:
class SearchResultsLoader {
...
func searchForItems(
matching query: String
) -> AnyPublisher<[Item], SearchError> {
guard !query.isEmpty else {
return .fail(with: SearchError.emptyQuery)
}
if let cachedItems = cache.value(forKey: query) {
return .just(cachedItems)
}
...
}
}
Not only is the above code much more compact than our previous implementation, our return statements can now actually be read as proper English sentences — “Fail with search error: empty query” and “Return just cached items” — which is most often a good sign when it comes to readability.
Conclusion
Even though generic APIs can often be quite complicated, the fact that they contain such rich type information gives us a lot of opportunities to extend them with very specific convenience APIs — that can then utilize that type information to actually reduce the amount of complexity and verbosity needed to perform common tasks.
Of course, we should always aim to strike a nice balance between the amount of convenience APIs that we have to maintain, and the value that we’re getting from them. My personal approach is to follow “The rule of threes”, and only create an abstraction when I’ve encountered three call sites that require the same kind of boilerplate.
What do you think? Would any of the above extensions be useful within your code base? Let me know — along with any questions, comments or feedback that you might have — either via Twitter or email.
Thanks for reading! 🚀