<In his article about Kotlin exceptions, Roman Eli...
# getting-started
j
In his article about Kotlin exceptions, Roman Elizarov says:
Copy code
val number = "123".toInt()
In the above code it is pretty clear that a failure of
toInt
function is a bug in the code and so it throws a
NumberFormatException
if the given string does not represent an integer. But what if some
string
came from a user input field and application logic shall deal with invalid input by replacing it with a default value?
The solution is to use
val number = string.toIntOrNull() ?: defaultValue
, which is explained with:
It indicates an error through its result value, using
null
in this particular case.
I understand the logic behind this, but later he writes the following function:
Copy code
fun updateOrderQuantity(orderId: OrderId, quantity: Int) {
    require(quantity > 0) { "Quantity must be positive" }
    val order = loadOrder(orderId)
    order.quantity = quantity
    storeOrder(order)
}
What if the quantity came from a user input? Should I write a new function
updateOrderQuantityOrNull
? Is it idiomatic for a function to return a nullable value but to throw an exception on an I/O error?
Also, lets imagine the
loadOrder
function does an HTTP request to retrieve the order and expects a JSON response with specific fields (the
Order
class is serializable):
Copy code
fun loadOrder(id: OrderId): Order {
    httpClient.get("<https://example.com/orders/$id>").body()
}
Now consider that, for some reason, the server returns an incorrect JSON structure. How the
loadOrder
function should handle this? Should we let
kotlinx.serialization
throw an exception and catch it at the top of the application stack? Or should we return a
null
value?
l
I’d argue you at least don’t want to let a kotlinx.serialization exception bubble up, since it leaks an implementation detail (what happens if you switch the serialization library later). If you go the exception route, you should catch and throw your own exception.
j
The null value pattern only works when there is a limited amount of possible reasons for the function to fail
2
l
I’d also add that it works in cases when you don’t care why it failed. That’s why it may be good to supply both.
j
Thank you. And what about using a sealed class to express what failed? But maybe this is not appropriate in case of a serialization exception and, like @Landry Norris said, I should rethrow my own exception?
l
A sealed class is a viable choice. It can get a little verbose (can’t directly return a result, but a Success wrapper or a Failure wrapper).
c
Another question to ask in this situation is: where is the
updateOrderQuantity
being called, and if the
quantity
were validated in the UI, would that fix the issue in that function? Assuming
updateOrderQuantity
is part of the “repository layer” of you app and is making some change (submitting an API call, updating the local DB, etc), then you should generally want to be pretty strict about your validation checks. Don’t trust anything passed in, make the check yourself. Even if the UI validates the
quantity
in the UI and shows the user a nice error message, you still want the check so that things still get processed properly if that same function is called from some other point in your application, which may not have that same UI validation. As for how exactly to model your “service calls”, it basically comes down to whether you want to set up your app to handle exceptions, or to branch the logic with result types (a null return value being one type of “result”, but you can use more explicit things like sealed classes, the built-in Result class, Arrow, etc.). As a general rule, exceptions are easy to implement and pretty easy to handle (you can handle errors centrally without a bunch of intermediate steps) but are also easy to mess up or forget to handle. A centralized error-handler can also handle a bunch of different types of exceptions without necessarily needing to know the specific error that occurred (just show a simple message like “oops, something went wrong”). Result types help force you to handle the errors, but can get a bit cumbersome as they need to be handled through all the intermediary layers between the UI and the service call. They give you a lot of insight into what went wrong, a lot of control on recovering from those errors, and generally a greater guarantee that you app is. behaving as you expect. But again, that comes at the cost of more boilerplate and needing to be more intentional about how you develop your app.
j
From my understanding, it is up to me to make a choice between a sealed class or an exception, depending on the layer the code is working on and the verbosity I want to bring to my code. Thank you all for your help!
l
I’d recommend making a choice for the project and sticking with it throughout to reduce confusion.
👍 2
j
Noted!
FYI, I went with this:
Copy code
sealed class ExchangedCode {
    object InvalidState : ExchangedCode()
    data class Failure(val errorCode: String, val errorDescription: String?) : ExchangedCode()
    data class Success(val accessToken: String) : ExchangedCode()
}

suspend fun exchangeCodeForAccessToken(code: OauthCode, state: OauthState): ExchangedCode {
    if (!states.remove(state)) return ExchangedCode.InvalidState

    val response = httpClient.get {
        url.takeFrom(generateUrl())
    }

    val payload: Response = response.body()
    if (payload.accessToken == null) {
        return ExchangedCode.Failure(payload.error!!, payload.errorDescription)
    }

    return ExchangedCode.Success(payload.accessToken)
}
I really appreciate the fact I can easily handle the various cases with `when`:
Copy code
when (val result = OauthClient.exchangeCodeForAccessToken(code, webSocketId)) {
    Client.ExchangedCode.InvalidState -> call.respondText("Invalid or expired OAuth state.", status = HttpStatusCode.BadRequest)
    is Client.ExchangedCode.Failure -> call.respondText("Code exchange failed: ${result.errorDescription ?: result.errorCode}", status = HttpStatusCode.BadRequest)
    is Client.ExchangedCode.Success -> call.respondText("Here is your access token: ${result.accessToken}")
}