ProAndroidDev

The latest posts from Android Professionals and Google Developer Experts.

Follow publication

Considering all unhappy paths in a type-safe way in modern Android

These last weeks, I had the chance to think about the best error handling solution for a new project at work. I read a lot about Railway Programming and the importance of considering the unhappy paths from the get-go.
I also read some articles and checked some GitHub projects Android/Kotlin specific to see how people in the community are solving this.

If I had to sum up my findings in a small code snippet, this would be it:

The concept is simple: you wrap the return values of your functions that can have errors with Result and then the calling side needs to unwrap it to find if the operation was successful or not.
Note that SomeErrorType above, can be a domain-specific error class — usually a sealed class in that case — or it can be simply Throwable. I’ve seen both approaches.

Most times, if you are trying to follow the principles of Clean Architecture, there will be some domain logic component (like Use Cases) returning these “Results” to the presentation layer. It’s up to the presentation layer to decide if and what message to show the user. As always, code is our best friend, lets see some:

And then in the ViewModel:

Alright, at this point we probably want to let the user know what went wrong. But..all we know is that our error is of type SomeErrorType, so:

  • If that type is Throwable then what exceptions should we expect here?
  • If that type is a sealed class with all our app’s errors, would we handle them all here? Even though our Use Case is not going to return all those error types? Or maybe we could delegate that “mapping” from domain errors to messages on one component with that single responsibility. But then what if in this specific screen, our general message for a specific error is not what we want to show?

These were questions I kept asking myself while I was fiddling around with some sample code.
Around this time, I found kotlin-result, which is just an example (there are others) of a better way of handling errors, in my opinion. Here is a nice article about it by Adam Bennett.
For simplicity, we can change our initial Result class to look more like what kotlin-result offers us:

Besides this, kotlin-result also has some neat functions if you wanna chain a lot of “error-producing functions”. Even if you are using your own Result class, I definitely recommend creating some of those because you don’t want to end up having to check if (result is Error) return result for each of those function calls.

With this new Result 2.0, we now have typed Errors. Why does this help? Because now we can be explicit in what errors our Use Cases return. So the ideal scenario is for our Use Cases to return a specific set of errors (a sealed class with all errors as subtypes). Then in the ViewModel, we know exactly what errors we are expecting, and, if we use a when statement, we can be sure that if new errors are added, we will have to deal with them.

But this did not solve all my problems.
You see, most of our errors are not specific to a single Use Case and we also cannot expect to be able to reuse the whole hierarchy of errors always. Let’s see what I mean, again with some code:

Notice that we did not nest the errors inside the GetMagicCardUseCaseError. The only real difference is when using the classes. This way we don’t need to write the supertype name (example: GetMagicCardUseCaseError.InvalidCardNameFormat) which would be quite verbose.

So far this is all great. But what happens if we add another Use Case which also needs to return some of these Errors? This is really not hard to imagine. In the example, we are using three HTTP-related error entries. These are useful in all Use Cases where an HTTP network request happens. Should we copy them for all of these sealed classes? In non-trivial projects, I feel that would be insanity!

Enter — Sealed interfaces for the rescue 🙌

This is the solution I came up with while trying to achieve the type-safety I wanted without losing the flexibility to mix and match errors in different Use Cases.
The reason sealed interfaces are a better fit for this is that each error can belong to multiple sealed hierarchies.
So for each new function we implement, we can pick errors we are already using somewhere else and create that function’s little hierarchy of errors by making them implement our new sealed interface.

Let’s expand the errors from the last example, considering a new Use Case, and using sealed interfaces instead of sealed classes:

With this change, we don’t have to repeat any error class ever again, and we even added a new error hierarchy for ApiCallError.

This is not only for semantic reasons, we can now have our own wrapper for API calls that always returns ApiCallError in case of error. Maybe we’ll even have some Use Cases that can use ApiCallError as the return error type (if they don’t have any other unhappy path).

But, wait a second… Is this really right? Should ApiCallError really implement each UseCaseError and not the other way around? 🤔

When I arrived at this solution, it felt weird for some reason. Instinctively, I almost wanted to do it the other way around. In fact, I asked two great developers of our dear community to take a look at this article after I finished the first draft, and they too felt the exact same way! (Thank you so much Adam McNeilly and Adam Bennett ❤️)

But this is in fact how we can achieve what we want. Let’s take it step by step and maybe it will click for us.
We start with one of the hierarchies above in a way we are used to writing sealed hierarchies:

So far, it makes sense. Our Use Case could return a GetMagicCardUseCaseError and we would handle it in a way that we’d have to consider all its entries. Just as we want to. Notice that for this to work, the ApiCallError needs to extend the GetMagicCardUseCaseError, which is hinting at us already…

We still have some inconveniences though: first is the way we access each of these entries which is quite verbose, and second is that each entry of ApiCallError cannot belong to any other hierarchy.
The first is easy, we just remove the nestings and place each entry at top level. Everything will work as we expect it to.
To solve the second, we can replace class keyword with interface. In Kotlin (and in Java), a class can implement multiple interfaces but it can only extend from a single class, so here we can take advantage of the interface feature. We don’t have any reason to need a class anyway!

This is what we get once we do that:

Ahh, things are making sense… Now if we have other “UseCaseErrors” that should contain ApiCallError as their entries, we need to make ApiCallError implement those “UseCaseErrors” sealed interface.

It’s definitely a little bit weird at first, but the more I get used to the idea, the more I see how it all makes sense.

Let’s see what other advantages we get with this approach:

  • “Free” documentation that cannot become obsolete for your UseCases

Ok, I cheated a little bit with this one. You can technically have the same without using sealed interfaces, but as we saw previously we’d have to copy all common errors or have some other ugly workaround. So.. I’ll keep my cookie 🍪 😁
Jokes aside, this is true value for your project that cannot be understated. If you do this, you’ll always know all possible unhappy paths each Use Case has. Just imagine entering a new team and finding that they have this system in place: a set of Use Cases that are very telling of the system, and for each of them a little defined set of errors that can occur 😍.
The IDE “Hierarchy” window (ctrl +H on macOS while selecting one of the classes) will help you to easily see what errors it contains, even if they are on different files:

Hierarchy window on Android Studio IDE (ctrl +H on macOS while selecting one of the classes)

Sealed interfaces allow for subclasses to be defined in different files, as long as they are contained in the same package. I already made use of this feature in my Jetpack Compose navigation library (shameless plug 😄)
Maybe you’ll like having all errors in the same package, maybe you won’t. Personally, I like it and I feel is a small price to pay to have this safety and flexibility combined, even if you don’t.

If somewhere down the line, we need to add a new unhappy path in the use case, we’ll have to make the new error implement the sealed interface we had already defined as the return error type, and then consuming classes will have a compile-time error until we go there and deal with the error.
How cool is that?

  • Error types can extend other classes besides implementing your sealed interface

Note that I am using objects here for simplicity, but you can use whatever you want. For example, you can use Exceptions to have a stack trace to print out. Subtypes can extend from whatever and still be part of the sealed hierarchy!

  • Flexibility
  1. Need a sub-hierarchy of errors related to API calls? Sure, do it! (we did that in the last examples)
  2. Have multiple layers that each can produce errors and all those errors should end up in the ViewModel? No problem:

Notice how the repository sealed interface is a subtype of the corresponding use case one. This is for the same reason we saw before withApiCallError being a subtype of the Use Case types.

With this, all errors that might be added to the repository will be automatically proxied to the View Model and it will be a compile-time error if we don’t handle them. But only if we want to: we could also deal with the errors at the use case level and maybe proxy fewer of them to the UI if we feel like we don’t need as much granularity of errors to show the user (we have to remove the PostNewDeckUseCaseError supertype out of CreateNewDeckError if we do that though).

Conclusion

Maybe it looks weird at first, as the project grows the ApiCallError(for example) could implement a LOT of sealed interfaces, but if you think about it, that is exactly right: those errors “belong” to each of those use cases.

That is even another bit of documentation, we can check what use cases an error can occur in by looking at the interfaces it implements. Just as we can check all errors that a use case can return by looking at all the children of that use case’s error hierarchy.

I won’t finish the post without showing you the same example we started with but now with the type-safety and flexibility of our approach:

Considering the repository error type is ApiCallError, then this error hierarchy would suffice for our Use Case.

And in the ViewModel:

And that is it for today!
I don’t want to pretend like this is some silver bullet solution that is the best fit for all projects. As is the case with almost anything related to programming, “it depends” is your answer. That said, I think it is a cool approach that might be beneficial for some projects and has a lot of upsides.

Let me know in the comments what you feel about the approach. If I am missing something obvious, and if I was able to explain it in a nice way.

Hopefully, some of you noticed my “Magic the Gathering” related code 😃. It has been a while since I played it. Maybe now, after writing this I can do some games! (if my newborn allows me to 👶 🍼)

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Published in ProAndroidDev

The latest posts from Android Professionals and Google Developer Experts.

Written by Rafael Costa

Android developer with a focus in writing clean and testable code, he loves to create tools for other developers even more than making Android apps.

Responses (4)

Write a response