Arrow of Outrageous Error Handling

David Rawson - July 2021

Android Worldwide

Strategies from FP

interface PokemonService {

    @GET("pokemon")
    fun fetchPokemonList(
        @Query("limit") limit: Int = 20,
        @Query("offset") offset: Int = 0
    ): PokemonResponse
}

An innocent piece of code

What exceptions can this function throw? 🤔

java.net.UnknownHostException
javax.net.ssl.SSLHandshakeException
java.net.SocketTimeoutException
com.squareup.moshi.JsonDataException

🤯

val result: String = pokemonService.fetchPokemonList() ❌
// Type mismatch: Required String but found PokemonResponse
@Dao
interface PokemonDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertPokemonList(pokemonList: List<Pokemon>)
}

Another innocent piece of code

What exceptions can this throw? 🤔

🤯

Concurrency and exceptions

https://github.com/skydoves/Pokedex

https://developer.android.com/jetpack/guide

Use case

☎️

interface PokemonService {

    @GET("pokemon")
    fun fetchPokemonList(
        @Query("limit") limit: Int = 20,
        @Query("offset") offset: Int = 0
    ): Call<PokemonResponse>
}
@Dao
interface PokemonDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertPokemonList(pokemonList: List<Pokemon>)
}
class PokemonRepository(
    private val pokemonService: PokemonService,
    private val pokemonDao: PokemonDao,
    private val executorService: ExecutorService
) {

    fun fetchPokemonList(
        onSuccess: (List<Pokemon>) -> Unit,
        onError: (Throwable) -> Unit
    ) {
        pokemonService.fetchPokemonList()
            .enqueue(
                object : Callback<PokemonResponse> {
                    override fun onResponse(
                        call: Call<PokemonResponse>,
                        response: Response<PokemonResponse>
                    ) {
                        if (!response.isSuccessful) {
                            onError(HttpException(response))
                        }

                        val body = response.body() ?: onError(HttpException(response))
                        executorService.execute {
                            try {
                                pokemonDao.insertPokemonList(body)
                            } catch (t: Throwable) {
                                onError(t)
                            }
                            onSuccess(body)
                        }
                    }

                    override fun onFailure(call: Call<PokemonResponse>, t: Throwable) {
                        onError(t)
                    }
                }
            )
    }
}

Nesting (fireball shape) represents cognitive complexity

https://www.sonarsource.com/docs/CognitiveComplexity.pdf

@Dao
interface PokemonDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertPokemonList(pokemonList: List<Pokemon>): Completable
}
interface PokemonService {

    @GET("pokemon")
    fun fetchPokemonList(
        @Query("limit") limit: Int = 20,
        @Query("offset") offset: Int = 0
    ): Single<PokemonResponse>
}

Have to understand Single and Completable to work with this code 😟

class PokemonRepository(
    private val pokemonService: PokemonService,
    private val pokemonDao: PokemonDao,
) {

    fun fetchPokemonList(): Single<List<Pokemon>> {
        return pokemonService.fetchPokemonList()
            .flatMap {
                val results = it.results
                pokemonDao.insertPokemonList(it.results)
                    .toSingle { results }
            }
    }
}

pokemonRepository.fetchPokemonList()
        .subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.main())
        .subscribe(
            {
                // onSuccess
                viewState.value.result = it 
            },
            {
                // onError - we only know it's assignable from Throwable
                when (it) {
                    is HttpException -> viewState.value.error = Error.SERVER_ERROR
                }
            }
        ).addTo(disposables) // manually manage subscription
@Dao
interface PokemonDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertPokemonList(pokemonList: List<Pokemon>)
}
interface PokemonService {

    @GET("pokemon")
    suspend fun fetchPokemonList(
        @Query("limit") limit: Int = 20,
        @Query("offset") offset: Int = 0
    ): PokemonResponse
}

No need to wrap return type

class PokemonRepository(
    private val pokemonService: PokemonService,
    private val pokemonDao: PokemonDao,
) {

    suspend fun fetchPokemonList(): List<Pokemon> {
        val pokemon = pokemonService.fetchPokemonList().results
        pokemonDao.insertPokemonList(pokemon)
        return pokemon
    }
}
viewModelScope.launch {
    try {
        val pokemon = pokemonRepository.fetchPokemonList()
        mutableViewState.value.result = pokemon
    } catch (t: Throwable) {
        if (t is HttpException) {
            mutableViewState.value.error = Error.SERVER_ERROR
        }
    }
}

Write imperative code without combinators like flatMap

try/catch seems inelegant here

no need to manage subscription

flow<List<Pokemon>> {
    pokemonRepository.fetchPokemonList()
}
    .flowOn(Dispatchers.Main)
    .onEach { mutableViewState.value.result = it }
    .catch { t ->
        if (t is HttpException) {
            mutableViewState.value.error = Error.SERVER_ERROR
        }
    }
    .collect()

Still no type safety - we only know t is a Throwable.

viewModelScope.launch {
    try {
        val pokemon = pokemonRepository.fetchPokemonList()
        mutableViewState.value.result = pokemon
    } catch (t: Throwable) {
        if (t is HttpException) {
            mutableViewState.value.error = Error.SERVER_ERROR
        }
    }
}

Functional companion to Kotlin's standard library

def arrow_version = "0.13.2"
dependencies {
    implementation "io.arrow-kt:arrow-fx-coroutines:$arrow_version"
}

Can we do better? 🤔

val right: Either<Throwable, String> = Either.Right("a")
val left: Either<Throwable, String> = Either.Left(Throwable())

val rightResult = right.map {
    it.plus("b")
}.map {
    it.plus("c")
} // Either.Right("abc")

val leftResult = left.map {
    it.plus("b") // short circuit
}.map {
    it.plus("c")
} // Either.Left(Throwable())
sealed class DataSourceException : Exception() {
    object ConnectivityException : DataSourceException()
    object InternalServerException : DataSourceException()
    object DatabaseException : DataSourceException()
}
class RemotePokemonDataSource(private val pokemonService: PokemonService) {

    suspend fun fetchPokemonList(
        limit: Int = 20,
        offset: Int = 0
    ): Either<DataSourceException, PokemonResponse> =
        Either.catch {
            pokemonService.fetchPokemonList(limit, offset) 
        }.mapLeft { it.toDataSourceException() }

    private fun Throwable.toDataSourceException(): DataSourceException = TODO()
}
class PokemonRepository(
    private val pokemonDataSource: PokemonDataSource,
    private val pokemonDao: PokemonDao,
) {

    suspend fun fetchPokemonList(): Either<DomainException, List<Pokemon>> {
        return pokemonDataSource.fetchPokemonList()
            .mapLeft { dataSourceException ->
                dataSourceException.toDomainException()
            }
            .flatMap { pokemonResponse ->
                insertToDb(pokemonResponse.results).map {
                    pokemonResponse.results
                }.mapLeft {
                    it.toDomainException()
                }
            }
    }
}
import arrow.core.Either
import arrow.core.computations.either
import arrow.core.flatMap

class PokemonRepository(
    private val pokemonDataSource: PokemonDataSource,
    private val pokemonDao: PokemonDao,
) {

    suspend fun fetchPokemonList(): Either<DomainException, List<Pokemon>> {
        return fetchPokemonListFromDataSource().mapLeft { it.toDomainException() }
    }

    private suspend fun fetchPokemonListFromDataSource(): Either<DataSourceException, List<Pokemon>> =
        either {
            // monad comprehension allows you to write in an imperative style 
            // without combinators like flatMap
            val response = pokemonDataSource.fetchPokemonList(20, 0).bind()
            val results = response.results
            insertToDb(results).bind()
            // binding is type safe, will fail if insertToDb left type parameter is not 
            // DataSourceException
            results
        }
}
viewModelScope.launch {
    repository.fetchPokemonList()
        .fold(
            ifLeft = { domainException ->
                @Exhaustive
                when (domainException) {
                    is ConnectivityException -> mutableViewState.value.error = R.string.no_connection
                    is ServerException -> mutableViewState.value.error = R.string.server_exception
                    is UnknownException -> mutableViewState.value.error = R.string.storage_exception
                }
            }, ifRight = { list ->
                mutableViewState.value.result = list
            }
        )
}

exit the world of Either using fold

we don't have to guess what errors might bubble up from below

import arrow.core.Either
import arrow.core.computations.either
import arrow.core.flatMap

private suspend fun fetchPokemonListFromDataSource(): Either<DataSourceException, List<Pokemon>> =
    either {
        val response = pokemonDataSource.fetchPokemonList(20, 0).bind()
        val results = response.results
        insertToDb(results).bind()
        results
    }

short circuit ⚡

What if we don't want short circuiting? 🤔

sealed class PokemonNameError {
    object InvalidSuffix : PokemonNameError()
    object TooLong : PokemonNameError()
    object Empty : PokemonNameError()
}

How do we represent this? 🤔

data class State(
    val pokemonName: PokemonName?,
    val errors: List<PokemonNameError>
)
val noOpinion = State(
    pokemonName = null,
    errors = emptyList()
)
data class State(
    val pokemonName: PokemonName?,
    val errors: List<PokemonNameError>
)

val valid = State(
    pokemonName = PokemonName("Bulbasaur"),
    errors = emptyList()
)
data class State(
    val pokemonName: PokemonName?,
    val errors: List<PokemonNameError>
)

val invalid = State(
    pokemonName = null,
    errors = listOf(InvalidSuffix, TooLong)
)
data class State(
    val pokemonName: PokemonName?,
    val errors: List<PokemonNameError>
)

val meaningless = State(
    pokemonName = PokemonName("Pikachu"),
    errors = listOf(InvalidSuffix, TooLong)
) // nothing prevents us creating a meaningless instance 😢
data class State private constructor(
    val pokemonName: PokemonName?,
    val errors: List<PokemonNameError>
) {
    companion object {
        fun create(pokemonName: PokemonName?, errors: List<PokemonNameError>) {
            if (pokemonName != null && errors.isNotEmpty()) {
                throw IllegalArgumentException()
            }
            return State(pokemonName, errors)            
        }
    }
}

val meaingless = State.create(
    pokemonName = PokemonName("Pikachu"),
    errors = listOf(InvalidSuffix, TooLong)
) 

// IllegalArgumentException at runtime. Can we rely on the type system instead?

Model with non-empty list instead 👍

import arrow.core.Nel
import arrow.core.NonEmptyList
import arrow.core.nonEmptyListOf


lateinit var a : NonEmptyList<PokemonNameError> 
lateinit var b : Nel<PokemonNameError>

a = nonEmptyListOf() // compilation failure
data class State(
    val pokemonName: Validated<Nel<PokemonNameError>, PokemonName>?
)

typealias ValidatedNel<E, A> = Validated<Nel<E>, A>

data class State(
    val pokemonName: ValidatedNel<PokemonNameErrors, PokemonName>?
)
private fun String.validatedLength(): ValidatedNel<PokemonNameError, String> =
    when {
        length < 16 -> valid() // wraps `this` (String receiver) in Validated.Valid
        else -> PokemonNameError.TooLong.invalidNel()
    }

private fun String.validatedSuffix(): ValidatedNel<PokemonNameError, String> =
    when {
        endsWith("mon") || endsWith("chu") || endsWith("saur") -> valid()
        else -> PokemonNameError.InvalidSuffix.invalidNel()
    }

private fun String.validatedNonEmpty(): ValidatedNel<PokemonNameError, String> =
    when {
        isEmpty() -> PokemonNameError.Empty.invalidNel()
        else -> valid()
    }
fun String.validatePokemonName(): ValidatedNel<PokemonNameError, PokemonName> =
    validatedNonEmpty().zip(
        validatedLength(),
        validatedSuffix()
    ) { _, _, _ -> PokemonName(this) }
    
fun main() {
    val name = "Bulbasaur"
    println(name.validatePokemonName())
    //Validated.Valid(PokemonName(value=Bulbasaur))
}

fun main() {
    val name = "Daviddddddddddddddddddddddddddddddddddd"
    println(name.validatePokemonName())
    //Validated.Invalid(NonEmptyList(TooLong, InvalidSuffix))
}
data class PokemonNameError(val reasons: Nel<Reason>) {

    sealed class Reason {
        object InvalidSuffix : Reason()
        object TooLong : Reason()
        object Empty : Reason()
    }
}

fun String.validatePokemonName(): Validated<PokemonNameError, PokemonName> =
    validatedNonEmpty().zip(
        validatedLength(),
        validatedSuffix()
    ) { _, _, _ -> PokemonName(this) }.mapLeft { PokemonNameError(it) }
    
fun main() {
    val name = "Daviddddddddddddddddddddddddddddddddddd"
    println(name.validatePokemonName())
    //Validated.Invalid(PokemonNameError(reasons=NonEmptyList(TooLong, InvalidSuffix)))
}

Questions?

@d_rawers

Slides available on Twitter

Arrow of Outrageous Error Handling

By David Rawson

Arrow of Outrageous Error Handling

  • 1,115