RxSwift: Better Error Handling With CompactMap

Use one of RxSwift 5’s newest features to streamline your code.

Michael Long
May 19 · 7 min read

Study RxSwift long enough, say, a week or so, and you’re bound to run across some variant of the following code:

class NoErrorViewModel {    var data: Observable<[String]>!
var dataService = DataService()
init(load: Observable<Void>) {
data = load
.flatMapLatest { [unowned self] _ in
self.dataService.load()
.catchErrorJustReturn([])
}
.observeOn(MainScheduler.instance)
.share()
}
}

Here we want to return an array of strings from our data service every time a load event occurs. Say, on every viewWillAppear().

So, we use flatMapLatest() to wrap our asynchronous API call into an observable, share() the result just to make sure multiple subscriptions to data don’t trigger multiple API calls for the same event, and then assign the result to our view model’s data observable back on the main thread so we can update our UI. All well and good.

However, in order to keep our event stream alive we need to catch any errors that might occur. If we don’t, and we let an error escape from our flatMapLatest() to the parent stream, then the error will propagate down the chain and terminate any subscriptions to our data.

That, in turn, would prevent any more load events from loading our data.

That’s not good, so inside the flatMap we see the following statement following our call to our data service:

.catchErrorJustReturn([])

Which traps any and all errors and returns an empty array instead.

Now, this is fine for an example, but blindly catching and eating our errors doesn’t really cut it for a production app. If we have an error, we generally need to inform our user that one occurred, the nature of the error, and what can be done about it.

Silently failing and returning no data at all isn’t really an option. Would you like to launch your banking app to check your balance and see nothing at all in your account?

Proably not. So let’s do something about it.

Classic RxSwift Error Handling

So now we want to return our data… and return an error if no data is to be found. In short, our view model needs to expose an Observable<[String]> for our data and expose an Observable<String> for an error.

One trigger, two observables, and we can’t let the error escape the flatMap. How?

Well, we could return a tuple of an error or our data, or we could map our data into a brand new Swift 5 Result… but we’re not. Instead, we’re going to use one of RxSwift’s lesser known operators: materialize.

Materialize

Using materialize inside of our flatMapLatest converts the result from Observable<[String]> to Observable<Event<[String]>>, or an event stream of events.

An event stream of events? That’s a bit meta, so let’s look at the code in action.

class MaterializingErrorViewModel {    var data: Observable<[String]>!
var error: Observable<String>!
var dataService = DataService() init(load: Observable<Void>) {
let loading = load
.flatMapLatest { [unowned self] _ in
self.dataService.load().materialize()
}
.observeOn(MainScheduler.instance)
.share()
data = loading
.map { $0.element }
.filter { $0 != nil }
.map { $0! }
error = loading
.map { $0.error?.localizedDescription }
.filter { $0 != nil }
.map { $0! }
}
}

Here we replace our catchErrorJustReturn() function with a materialize() function inside of our flatMapLatest().

We then assign the end result of that sequence to a temporary variable named loading. If the load operation succeeds, then the result is of type Event.next<[String]>. If it’s an error, then it’s Event.error(Swift.Error).

Next, we branch our temporary observable stream into two separate observable streams: one that will contain our data, and another that contains our error, if any.

Our Data Stream

We access the data hidden inside the event using .element, which returns an optional value of type Element, which is the original type of our data value before we materialized it.

In this example, that converts the data side of the stream to an optional array of strings. We then filter our optional value on nil, with the end result being that we only let actual data values pass down the stream. If the event doesn’t contain data, it’s blocked and any subscribers to our observable see nothing.

Finally, we explicitly unwrap the value that we know exists, giving us the data we had in the first example, an Observable<[String]>.

Our Error Stream

We do the same for the error branch, using $0.error?.localizedDescription to see if our materialized stream contains an error event and in the proces extracting the localized error returned by the API.

We then do the same filter and explicit unwrap sequence as we did for our data. The result? An Observable<String> that contains our error message and fires whenever an error occurs.

So. One trigger, a data observable, an error observable, and we didn’t let an error escape the flatMap and ruin our observable chain. Life is good.

Except…

I don’t know about you, but to me the above code seems a bit redundant in the sequence of operations we have to perform on both the data side and the error side of our observable chain.

So how to fix it?

Well, this is where RxSwift 5 comes to the rescue.

RxSwift 5 and CompactMap

RxSwift 5 added a new feature to observable streams that mirrors the addition of a feature added to Swift Sequences: compactMap.

In Swift, using compactMap() on an array of optional values returns a new array of values with all of the optional values filtered out. To put it another way, it can convert a type of Array<[String?]> to an Array<[String]>.

In RxSwift, compactMap() performs a similar function, letting us map a stream’s elements to optional values and then filtering out any resulting optional (nil) values in the process.

The RxSwift 5 CompactMap Example

Apply compactMap() to our last example and we get the following…

class CompactMapErrorViewModel {    var data: Observable<[String]>!
var error: Observable<String>!
var dataService = DataService() init(load: Observable<Void>) {
let loading = load
.flatMapLatest { [unowned self] _ in
self.dataService.load().materialize()
}
.observeOn(MainScheduler.instance)
.share()
data = loading
.compactMap { $0.element }
error = loading
.compactMap { $0.error?.localizedDescription }
}
}

In each case, compactMap() replaces our map, filter, map sequence on data and error with a single operator.

Much cleaner.

Regarding observeOn and Performance

One might ask about the placement of .observeOn(MainScheduler.instance) in our shared loading sequence.

Wouldn’t we be better off moving our observeOn() function and adding one beneath each compactMap() operation? That would appear to keep more of our processing code in the background, before we switch our final result back to the main processing thread… and you’re correct, it would.

It would also add not one, but two fairly heavy background to foreground thread context switches to our code. As each compactMap() function is pretty lightweight, in this particular instance I deemed it better to perform my context switch only once.

If, on the other hand, I needed to perform some operation on each and every element of my loaded array, I probably would have went the other way and moved observeOn() to each of my branches.

class CompactMapOperationViewModel {    var data: Observable<[String]>!
var error: Observable<String?>!
var dataService = DataService() init(load: Observable<Void>) {
let loading = load
.flatMapLatest { [unowned self] _ in
self.dataService.load().materialize()
}
.share()
data = loading
.compactMap { $0.element?.map { "Hello, \($0)" }
.observeOn(MainScheduler.instance)
error = loading
.map { $0.error?.localizedDescription }
.observeOn(MainScheduler.instance)
}

If you have sharp eyes, you might also note that our last compactMap() on our now optional error observable was changed to a map() instead.

In this revised case we’re letting nil error string events pass, which in turn lets us clear out any previous error message in our view controller’s error label when data is successfully loaded.

Both cases should make it clear that you need to think about your code and UI requirements and the operations that you’re performing, and adjust your coding patterns accordingly.

CompactMap / Unwrap Bonus Round

You may have come across a library of RxSwift community extensions called RxSwiftExt. If so, you might have seen or even used an RxSwiftExt operator named unwrap().

Unwrap, to quote the code: “Takes a sequence of optional elements and returns a sequence of non-optional elements, filtering out any nil values.”

Unwrap is a handy little function in RxSwift, given just how prevalent optionals are used in the Swift language. In fact, it’s so handy that it’s often been proposed that it be added to the core RxSwift language itself.

Look inside the implementation, however, and you’ll see something a bit familiar…

public func unwrap<T>() -> Observable<T> where Element == T? {
return self.filter { $0 != nil }.map { $0! }
}

Yep. It’s the same filter, map, explicitly unwrap sequence we’ve seen before.

Which means in RxSwift 5 you can do:

let stringIsRequired: Observable<String> = optionalString
.compactMap { $0 }

Yep. With compactMap(), the equivalent to unwrap() is now effectively part of the RxSwift core.


So that’s it. The addition of compactMap() to RxSwift 5 lets us write less code and is more performant and memory efficient to boot, replacing three RxSwift operations with just one.

And as an added bonus, we can now easily unwrap() our optional event streams without using another library or by adding the extension to our own code base.

Personally, I think it’s a useful addition to your RxSwift toolbox. So useful, in fact, that like the variadic disposebag, I’m proud to note that compactMap was another of my minor contributions to the RxSwift project.

If you have any questions just leave ’em in the comments below.

Enjoy.


Addendum: This story continues in: RxSwift: The Complexity Tradeoff

Michael Long

Written by

Michael Long (RxSwifty) is a Senior Lead iOS engineer at CRi Solutions, a leader in cutting edge iOS, Android, and mobile corporate and financial applications.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade