Kotlin / Java error handling: how to do it right?



Source







Error handling plays a crucial role in any development. Almost everything can go wrong in the program: the user will enter incorrect data, or they may come such via http, or we made a mistake when writing serialization / deserialization and during the processing the program crashes with an error. Yes, it may corny run out of disk space.







spoiler

¯_ (ツ) _ / ¯, there is no single way, and in each specific situation you will have to choose the most suitable option, but there are recommendations on how to do it better.







Foreword



Unfortunately (or just such a life?), This list goes on and on. The developer constantly needs to think about the fact that somewhere an error may occur, and there are 2 situations:









And if the expected errors are at least localized, then the rest can happen almost everywhere. If we do not process anything important, then we can simply crash with an error (although this behavior is not enough and you need to at least add a message to the error log). But if right now the payment is being processed and you can’t just fall, but at least need to return a response about the unsuccessful operation?







Before we look at ways to handle errors, a few words about Exception (exceptions):







Exception





Source







The hierarchy of exceptions is well described and you can find a lot of information about it, so it makes no sense to paint it here. What still sometimes causes heated discussion is checked



and unchecked



errors. And although the majority accepted unchecked



exceptions as preferred (in Kotlin there are no checked



exceptions at all), not everyone agrees with this.







The checked



exceptions really had a good intention to make them a convenient error handling mechanism, but the reality made its adjustments, although the very idea of ​​introducing all exceptions that can be thrown from this function into the signature is understandable and logical.







Let's look at an example. Suppose we have a method



function that can throw a checked PanicException



. Such a function would look like this:







 public void method() throws PanicException { }
      
      





From her description it is clear that she can throw an exception and that there can be only one exception. Does it look quite comfortable? And while we have a small program, that's it. But if the program is slightly larger and there are more such functions, then some problems appear.







Checked exceptions require, by specification, that all possible checked exceptions (or a common ancestor for them) are listed in the function signature. Therefore, if we have a chain of calls a



-> b



-> c



and the most nested function throws some kind of exception, then it should be put down for everyone in the chain. And if there are several exceptions, then the topmost function in the signature should have a description of all of them.







So, as the program becomes more complex, this approach leads to the fact that exceptions at the top function gradually collapse to common ancestors and ultimately come down to Exception



. What in this form becomes similar to an unchecked



exception and negates all the advantages of checked exceptions.







And given that the program, as a living organism, is constantly changing and evolving, it is almost impossible to foresee in advance what exceptions may arise in it. And the result is the situation that when we add a new function with a new exception, we have to go through the entire chain of its use and change the signatures of all functions. Agree, this is not the most enjoyable task (even considering that modern IDEs do this for us).







But the last, and probably the biggest nail in checked exceptions “drove” lambdas from Java 8. In their signature there are no checked exceptions ¯_ (ツ) _ / ¯ (since you can call any function in lambda, with any signature), so any function call with a checked exception from the lambda forces it to be wrapped in an exception forwarding as unchecked:







 Stream.of(1,2,3).forEach(item -> { try { functionWithCheckedException(); } catch (Exception e) { throw new RuntimeException("rethrow", e); } });
      
      





Fortunately, in the JVM specification there are no checked exceptions at all, so in Kotlin you can not wrap anything in the same lambda, but simply call the desired function.







although sometimes ...

Although this sometimes leads to unexpected consequences, such as the incorrect operation of @Transactional



in the Spring Framework



, which "expects" only unckecked



exceptions. But this is more a feature of the framework, and perhaps this behavior in Spring will change in the near future github issue .







Exceptions themselves are special objects. Besides the fact that they can be "thrown" through methods, they also collect stacktrace at creation. This feature then helps with the analysis of problems and the search for errors, but it can also lead to some performance problems if the application logic becomes heavily tied to thrown exceptions. As shown in the article , disabling the stacktrace assembly can significantly increase their performance in this case, but it should be resorted to only in exceptional cases when it is really needed!







Error processing



The main thing to do with “unexpected” errors is to find a place where you can intercept them. In JVM languages, this can be either a stream creation point or a filter / entry point to the http method, where you can put a try-catch with handling unchecked



errors. If you use any framework, then most likely it already has the ability to create common error handlers, as, for example, in the Spring Framework, you can use methods with the @ExceptionHandler



annotation.







You can “raise” exceptions to these central processing points that we don’t want to handle in specific places by throwing the same unckecked



exceptions (when, for example, we don’t know what to do in a particular place and how to handle the error). But this method is not always suitable, because sometimes it may require to handle the error on the spot, and you need to check that all places of function calls are processed correctly. Consider ways to do this.







  1. Still use exceptions and the same try-catch:







      int a = 10; int b = 20; int sum; try { sum = calculateSum(a,b); } catch (Exception e) { sum = -1; }
          
          





    The main disadvantage is that we can “forget” to wrap it in a try-catch at the place of the call and skip the attempt to process it in place, because of which the exception will be thrown up to the common point of error processing. Here you can go to checked



    exceptions (for Java), but then we will get all the disadvantages mentioned above. This approach is convenient to use if error handling in place is not always required, but in rare cases it is needed.







  2. Use the sealed class as the result of a call (Kotlin).

    In Kotlin, you can limit the number of descendants of a class, make them computable at the compilation stage - this allows the compiler to verify that all possible options are parsed in the code. In Java, you can make a common interface and several descendants, however, losing compilation-level checks.







     sealed class Result data class SuccessResult(val value: Int): Result() data class ExceptionResult(val exception: Exception): Result() val a = 10 val b = 20 val sum = when (val result = calculateSum(a,b)) { is SuccessResult -> result.value is ExceptionResult -> { result.exception.printStackTrace() -1 } }
          
          





    Here we get something like a golang



    error approach when you need to explicitly check the resulting values ​​(or explicitly ignore). The approach is quite practical and especially convenient when you need to throw a lot of parameters in each situation. The Result



    class can be expanded with various methods that make it easier to get the result with an exception throwing above, if any (i.e. we do not need to handle the error at the place of the call). The main drawback will be only the creation of intermediate superfluous objects (and a slightly more verbose entry), but it can also be removed using inline



    classes (if one argument is enough for us). and, as a particular example, there is a Result



    class from Kotlin. True, it is for internal use only, as its implementation may change a little in the future, but if you want to use it, you can add the compilation flag -Xallow-result-return-type



    .







  3. As one of the possible types of claim 2, the use of the type from the functional programming of Either



    , which can be either a result or an error. The type itself can be either a sealed



    class or an inline



    class. Below is an example of using the implementation from the arrow



    library:







     val a = 10 val b = 20 val value = when(val result = calculateSum(a,b)) { is Either.Left -> { result.a.printStackTrace() -1 } is Either.Right -> result.b }
          
          





    Either



    best suited for those who love a functional approach and who like to build call chains.







  4. Use Option



    or nullable



    type from Kotlin:







     fun testFun() { val a = 10 val b = 20 val sum = calculateSum(a,b) ?: throw RuntimeException("some exception") } fun calculateSum(a: Int, b: Int): Int?
          
          





    This approach is suitable if the cause of the error is not very important and when it is only one. An empty answer is considered an error and is thrown higher. The shortest record, without creating additional objects, but this approach can not always be applied.







  5. Similar to item 4, only uses a hardcode value as an error marker:







     fun testFun() { val a = 10 val b = 20 val sum = calculateSum(a,b) if (sum == -1) { throw RuntimeException(“error”) } } fun calculateSum(a: Int, b: Int): Int
          
          





    This is probably the oldest error handling approach that came back from C



    (or even from Algol). There is no overhead, only a code that is not entirely clear (along with restrictions on the choice of the result), but, unlike step 4, it is possible to make various error codes if more than one possible exception is required.









findings



All approaches can be combined depending on the situation, and there is none among them that is suitable in all cases.







So, for example, you can achieve a golang



approach to errors using sealed



classes, and where it is not very convenient, move on to unchecked



errors.







Or use in most places a nullable



type as a marker that it was not possible to calculate the value or get it from somewhere (for example, as an indicator that the value was not found in the database).







And if you have fully functional code along with arrow



or some other similar library, then it is most likely best to use Either



.







As for http-servers, it is easiest to raise all errors to central points and only in some places combine the nullable



approach with sealed



classes.







I will be glad to see in the comments that you are using this, or maybe there are other convenient error handling methods?







And thanks to everyone who read to the end!








All Articles