The Power of Types for Errors

At KotlinConf 2019, I talked about the power of types. In essence, I discussed limiting the number of primitives we use in our code in favor of using custom types instead. In this way, we as developers will not only reduce the possibility of bugs by using the compiler, but we will also achieve more readable and self-documented types.

Today I ran into a situation which reminded me of the things I said in this talk.

kotlinconf

Where It All Started

I was working on some tracking code that’s part of the signup flow of our Android app. The goal was to improve the insights from the error states that users might run into. These could be local UX flaws, but they could also be issues that arise when trying to log in via third parties.

My initial code looked like this:

fun onError(type: ErrorType, details: String? = null) {
  // ...
}

The ErrorType is an enum that specifies the exact error type:

enum class ErrorType {
   IO,
   PASSWORD_INVALID,
   RECAPTCHA_FAILED
// ...
}

The additional parameter details are for extra information some errors might need. But let’s take a closer look at the method signature we had:

fun onError(type: ErrorType, details: String? = null)

I’d say these two parameters belong together, as the details describe the error. This means they have a very tight coupling, but they still don’t share any relationship! Rather, they are just two independent parameters.

Let’s Express This Relationship

The first idea for improving this situation is to wrap both parameters into one type:

data class Error(val type: ErrorType, val details: String?)
Our tracking method now looks much nicer:
fun onError(error: Error) {
  // ...
}

Once we do this, there will only be one parameter in our method, making it clear to the reader what to pass:

fun onError(error: Error) {
  //...
}

And they can’t pass in something wrong, as was possible when we had two independent parameters.

This only confirms the quote I shared in my talk from the late Robin Milner, a British computer scientist who once said:

Well-typed expressions cannot “go wrong.”

We Can Do Better

But we should not stop here! The method is now saved, but the two parameters have simply been moved to the Error class, so there is still a hidden relationship!

So, we should express this. We should not let the developer guess when to pass what and when or what not to!

An enum type could not hold these individual details, as enum values are simply constants, which are by definition immutable. However, with sealed classes, we can model this more precisely:

sealed class Error {
  object InvalidPassword : Error()
  object RecaptchaFailed : Error()
  class IOError(val details: String): Error()
// ...
}

The Next Step

Another thing that enums do not support is a hierarchy. Enum values are flat on one level of abstraction.

To compensate, we normally express this via the name:

enum class ErrorType {
   IO,
   EMAIL_DENIED,
   EMAIL_EXISTING,
   EMAIL_INVALID,
   PASSWORD_INVALID,
   RECAPTCHA_FAILED
}

But as I mentioned in my talk:

Don’t put in a name what can be in a type.

Now, once we get rid of the enum, it’s easy to introduce a hierarchy:

sealed class Error {
  sealed class SignInError {
     sealed class EmailError : SignInError() {
        object Denied : EmailError()
        object Invalid : EmailError()
        object Existing : EmailError()
     }
     class IOError(val message: String): SignInError()
  }
//...
}

As you might have noticed, we could even restrict the email-related errors to sign-in use cases. This way, we could handle all errors related to one use case at one point simply by using types.

And Then I Saw…

There was another area of the code that wasn’t using the full power of types. When trying to log in via social networks, the code normally first handles third-party library calls and then proceeds with the login flow to your own system with those results. On the other hand, when you log in directly via an email, you won’t have this first step.

The code had something like this:

// An error on Facebook/Google appeared.
fun onAuthenticationError(method: Method, error: Error)

Here, Method is another enum:

enum class Method {
   FACEBOOK, GOOGLE, EMAIL
}

But as the code comment says, onAuthenticationError() is meant only for Google and Facebook. A developer should not even be able to call it with Method.EMAIL. But nothing prevents us from doing this. We could add an assertion and crash on invalid input, but both runtime crashes and hidden code comments are not ideal. The ideal scenario would be that the compiler could check this upfront.

Thanks to our new error hierarchy, this can be solved very easily with types:

fun onAuthenticationError(error: Error.SocialError)

Here, SocialError is just another specialty in our sealed Error:

sealed class Error {
 sealed class SignInError {
   sealed class SocialError(val message: String) : SignInError() {
       class GoogleError(message: String) : SocialError(message)
       class FacebookError(message: String) : SocialError(message)
   }
  // ...
 }
}

Conclusion

In summary, types are very powerful. With the changes mentioned above, our code became easier to read and more expressive, and a new developer will know how to use the tracking methods simply by the types they need as arguments.

Here are a few key takeaways from this blog post to keep in mind:

  • If you see parameters that belong together but are treated separately, check if there is a hidden type.
  • If you see classes with fields that are only used in certain cases, maybe there is a hidden type hierarchy.
  • If you see methods that only allow a certain combination, secure them with a type.

And, of course, if you’re interested in learning more about this, you’ll find more examples in my slides and by watching the recording of the talk.