It's common for operations to fail and we should expect that. In Ivy Wallet we do not throw exceptions but rather make functions that can fail to return Either<Error, Data>.
Either is a generic data type that models two possible cases:
Either.Left
for the unhappy path (e.g., request failing, invalid input, no network connection)Either.Right
for the happy path
Simplified, Either
is just:
sealed interface Either<E, A> {
data class Left<E>(val data: E): Either<E, Nothing>
data class Right<T>(val data: A): Either<Nothing, A>
}
fun <E,A,B> Either<E, A>.fold(
mapLeft: (E) -> B,
mapRight: (A) -> B
): B = when(this) {
Either.Left -> mapLeft(data)
Either.Right -> mapRight(data)
}
// a bunch of more extension functions and utils
So in Ivy, operations that can fail (logically or for some other reason) we'll model using Either.
Imagine that we're building a program that buys BTC if its price is below $50,000.
interface BtcDataSource {
suspend fun fetchCurrentPriceUSD(): Either<String, PositiveDouble>
suspend fun buy(amount: PositiveDouble): Either<BuyError, Unit>
sealed interface BuyError {
data class IO(val e: Throwable) : BuyError
data object TooSmallAmount : BuyError
}
}
interface MyBank {
suspend fun currentBalanceUSD(): Either<Unit, PositiveDouble>
}
class CryptoInvestor @Inject constructor(
private val btcDataSource: BtcDataSource,
private val myBank: MyBank
) {
suspend fun buyIfCheap(): Either<String, PositiveDouble> = either {
val btcPrice = btcDataSource.fetchCurrentPriceUSD().bind()
// .bind() - if it fails, returns Either.Left and short-circuits the function
if(btcPrice.value > 50_000) {
// short-circuits and returns Either.Left with the msg below
raise("BTC is expensive! Won't buy.")
}
val myBalance = myBank.currentBalanceUSD().mapLeft {
"Failed to fetch my bank account balance."
}.bind()
btcDataSource.buy(myBalance).mapLeft { err ->
when(err) {
is BuyError.IO -> "Failed to buy because of an IO error - ${e.msg}"
BuyError.TooSmallAmount -> "Failed to buy because I'm poor."
}
}.bind() // maps the BuyError to String and short-circuits
// Bought BTC with my entire balance!
myBalance // <-- the last line returns the Either.Right
}
}
Let's analyze, simplified:
either {}
puts us into a "special" scope where the last line returnsEither.Right
and also gives us access to some functions:Operation.bind()
: if the operation fails, it terminates theeither {}
with operation'sLeft
value; otherwise,.bind()
returns the operation'sRight
valueraise(E)
: like throw but foreither {}
- terminates the function withLeft(E)
Either.mapLeft {}
: transforms theLeft
(error type) of theEither
. In the example, we do it so we can match the left type of theeither {}
We won't cover all but I'll list the ones that we often use in Ivy Wallet and find the most useful.
- either {}
- Either.bind()
- raise()
- ensure()
- Either.getOrNull()
- Either.map {} and Either.mapLeft {}
- Either.fold({},{})
- Either.catch({}){}
I strongly recommend allocating some time to also go through Arrow's Working with typed errors guide which covers much more.
- Either is a monad.
Either<Throwable, T>
is equivalent to Kotlin's stdResult
type.- Many projects create a custom
Result<E, T>
while they can just useEither
with all its built-in features. - It's similar to the Kotlin
Result
but it's better because the error type isn't constraint only toThrowable
. In fact,Either<Throwable, V>
is equivalent toResult<V>
In some rare cases, it's okay to
throw
a runtime exception. These are the cases in which you're okay and want the app to crash (e.g., not enough disk space to write in Room DB / local storage).