Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Kotlin Error Handling & The Result Monad

Kotlin Error Handling & The Result Monad

Kotlin doesn’t force you to handle functions that might throw exceptions, so how can we best ensure that our code is safe to execute whilst handling errors in a reasonable way? In this talk, we’ll look at some strategies that the team at Cuvva evaluated and discuss in-depth the solution they chose: the Result Monad. We’ll talk about how it works, discuss how utilising this strategy can help you, and how we adopted these to great effect at Cuvva.

Adam Bennett

July 14, 2020
Tweet

More Decks by Adam Bennett

Other Decks in Programming

Transcript

  1. Adam Bennett @iateyourmic
    Error Handling in Kotlin
    The Result Monad

    View Slide

  2. Introduction
    • Why errors in Kotlin are a tricky problem
    • What were the issues we were trying to solve
    • Why we chose the Result Monad
    • How we integrated it
    • Lessons learned

    View Slide

  3. Further Reading
    bit.ly/2Zlpzl1
    adambennett.dev

    View Slide

  4. Exceptions
    • Exceptions can be a valid part of control flow
    • Many places where exceptions are expected
    • Network requests
    • Loading a file that the user has selected
    • Domain logic - selecting an out of stock item
    • Model them where possible rather than try/catch

    View Slide

  5. Kotlin Exceptions
    • Kotlin has unchecked exceptions
    • Callers must check method signature
    • Declared using @Throws(Exception::class)
    • … But not required
    • We can’t rely on consumers to handle appropriately

    View Slide

  6. A Typical UseCase
    class FetchVehicles {
    suspend operator fun invoke(): List = try {
    fetchVehicles()
    } catch (e: Throwable) {
    emptyList()
    }
    }

    View Slide

  7. A Typical UseCase
    class FetchVehicles {
    suspend operator fun invoke(): List? = try {
    fetchVehicles()
    } catch (e: Throwable) {
    null
    }
    }

    View Slide

  8. A Typical UseCase
    class FetchVehicles {
    @Throws(NetworkException::class)
    suspend operator fun invoke(): List = fetchVehicles()
    }

    View Slide

  9. A Typical UseCase
    class FetchVehicles {
    suspend operator fun invoke(): List = fetchVehicles()
    }

    View Slide

  10. Our Dream API
    • Wrap functions that we expect to fail
    • Model both success and failure more succinctly
    • Map success and error types across data/domain/presentation boundaries
    • Chain these operations
    • Short-circuit on failure
    • Make it absolutely clear to consumer, but allow flexibility
    • Integrate well with Flow, Coroutines

    View Slide

  11. The Result Monad
    • Explicitly for modelling success and failure states
    • Satisfies all of our requirements
    • kotlin-result
    • github.com/michaelbull/kotlin-result

    View Slide

  12. val (value, error) = Ok("Hello world!")
    value shouldEqual "Hello world!"
    error shouldEqual null

    View Slide

  13. Ok("Hello world!")
    .map { it.dropLast(1) }
    // "Hello world"
    .map { it.length }
    // 11
    .map { it * 5 }
    // 55

    View Slide

  14. Err(Throwable("An exception"))
    .mapError { it.message }
    // "An exception"
    .mapError { it.length }
    // 12

    View Slide

  15. The Data Layer
    interface VehicleApi {
    @GET("/garage")
    suspend fun getVehicles(): List
    }
    class VehicleService(private val api: VehicleApi) {
    suspend fun getVehicles(): Result>, Throwable> =
    runCatching { api.getVehicles() }
    }

    View Slide

  16. The Data Layer
    suspend fun getPolicies(): Result =
    authService.getToken()
    .mapError { DomainError.AuthException() }
    .andThen { token ->
    vehicleService.getVehicles(token)
    .andThen { vehicles ->
    policyService.getPolicies(token, vehicles)
    }
    .map { policies -> policies.toDomain() }
    .mapError { throwable -> throwable.toDomainError() }
    }

    View Slide

  17. UseCases
    class FetchVehicles {
    suspend operator fun invoke(): Result =
    fetchVehicles()
    }

    View Slide

  18. In a ViewModel
    val vehicleList: VehicleDomain? = fetchVehicles()
    .get()

    View Slide

  19. In a ViewModel
    val vehicleList: VehicleDomain = fetchVehicles()
    .getOrElse { VehicleDomain.empty() }

    View Slide

  20. In a ViewModel
    viewModelScope.launch {
    fetchVehicles()
    .map { it.toPresentation() }
    .mapError { it.toErrorMessage() }
    .onSuccess { vehicles -> displayVehicles(vehicles) }
    .onFailure { msg -> displayError(msg) }
    }

    View Slide

  21. The Cuvva Codebase

    View Slide

  22. The Cuvva Codebase

    View Slide

  23. The Cuvva Codebase

    View Slide

  24. val transformer: FlowTransformer { upstream ->
    upstream.flatMapLatest {
    flow {
    emit(Update.Loading)
    emit(
    getPolicies()
    .fold(
    success = { policies -> Update.Success(policies) },
    failure = { error -> Update.Error(it) }
    )
    )
    }
    }
    }

    View Slide

  25. Problem solved?

    View Slide

  26. Lessons learned
    • Not a silver bullet
    • For modelling expected control flow
    • Solution is not to wrap everything!
    • Some types of exception must remain exceptional

    View Slide

  27. Lessons learned
    • Consume within Flows
    • Avoid using Flows with Result as a return type
    • …at least not if you combine them regularly

    View Slide

  28. Reasons Against
    • Fail fast
    • Local exceptions for control flow
    • Apathy
    • I/O
    • Performance
    • Public APIs
    • Functional programming is scary

    View Slide

  29. Lessons learned
    • Don’t forget logging!
    • Too easy to swallow exceptions
    • Failures with no logs are not fun to debug
    • Don’t wrap exceptions inside domain error types

    View Slide

  30. Logging
    inline fun runCatchingAndLog(
    block: () -> V
    ): Result =
    runCatching { block() }
    .onFailure { it.printStackTrace() }

    View Slide

  31. Typealiases
    Result
    typealias ApiResult = Result
    Flow>, DomainError>>
    typealias DomainFlow = Flow, DomainError>>

    View Slide

  32. To Sum Up
    • Fantastic tool for modelling control flow
    • Not a catch-all
    • Still have to consume 3rd-party APIs diligently
    • Result is not always appropriate

    View Slide

  33. Adam Bennett @iateyourmic
    Thank you!

    View Slide

  34. Addendum
    Exceptions in Kotlin
    • Kotlin has unchecked exceptions
    • Kotlin -> Java -> Bytecode
    • How?

    View Slide

  35. Exceptions in Kotlin
    • Checked exceptions are a Java feature, not JVM!
    • In bytecode, you can throw without restrictions
    • Kotlin compiler hides exceptions behind Throwable
    • Implemented by both checked and unchecked exceptions

    View Slide

  36. SneakyThrows
    fun throwSomething() {
    throw IOException()
    }

    View Slide

  37. SneakyThrows
    public final void throwSomething() {
    throw (Throwable)(new IOException());
    }

    View Slide

  38. SneakyThrows
    public static void sneakyThrow(Throwable e)
    throws E {
    throw (E) e;
    }
    private static void throwsSneakyIOException() {
    sneakyThrow(new IOException("sneaky"));
    }

    View Slide

  39. SneakyThrows
    try {
    return throwsSneakyIOException()
    } catch (IOException e) {
    e.printStackTrace()
    }
    return null

    View Slide

  40. SneakyThrows
    @Throws(IOException::class)
    fun throwsSneakyIOException() {
    throw IOException(“no longer sneaky")
    }

    View Slide

  41. SneakyThrows
    public final void throwsSneakyIOException() {
    throw (Throwable)(new IOException("no longer sneaky"));
    }

    View Slide

  42. SneakyThrows
    public final void throwsSneakyIOException() throws IOException {
    throw (Throwable)(new IOException("no longer sneaky"));
    }

    View Slide