LY Corporation Tech Blog

We are promoting the technology and development culture that supports the services of LY Corporation and LY Corporation Group (LINE Plus, LINE Taiwan and LINE Vietnam).

This post is also available in the following languages. Japanese

Improving code quality - Session 1: Don't cry over spilt errors

Hello, I'm Munetoshi Ishikawa, a mobile client developer for the LINE messaging app.

To boost our development productivity, we're committed to enhancing both our code quality and the culture of our development team. As part of this commitment, we've launched several initiatives, including the activities of our Review Committee. This committee takes a second look at merged code, offers feedback to both the reviewer and the author, and compiles insights from these reviews into a "Weekly Report" that we share every week.

The Weekly Report often covers topics relevant to platforms like Android and iOS, and offers tips related to programming languages such as Kotlin and Swift. While we aim to make the content broadly applicable to general programming practices, we frequently use Kotlin for our code examples.

Moving forward, I plan to make the Weekly Report a regular feature on this blog. The report you're currently reading is titled "Don't cry over spilt errors”, and I look forward to sharing it with you.

As for what kinds of initiatives we are undertaking to improve our code quality and development culture, please refer to Tackling the “quality” issue of a long-lived codebase we published on DroidKaigi 2021.

Don't cry over spilt errors

The function parseFooValue below is a function that retrieves a one to six-digit integer from a string starting with "foo:".

/**
 * Parses the given "FOO" data string then returns the parsed integer by wrapping with
 * [FooParseResult].
 *
 * The expected format is: "foo:(1-6 digit non-negative integer)".
 * For example, "foo:00" and "foo:123456"
 are valid while "foo : 1", "foo,2", "foo:-1" are not.
 *
 * This throws [IllegalArgumentException] if the given text format is invalid.
 */
 @Throws(IllegalArgumentException::class)
 fun parseFooValue(inputText: String): FooParseResult {
    val matchResult = FOO_FORMAT_REGEX.matchEntire(inputText)
    requireNotNull(matchResult) // Throws if `inputText` format is invalid

    val fooIntValue = matchResult.groupValues.getOrNull(1)?.toIntOrNull()
    return if (fooIntValue != null) {
        FooParseResult.Success(fooIntValue)
    } else {
        FooParseResult.InvalidRegexError
    }
 }

 /** Result model of parsing "FOO" integer from string value. */
 sealed class FooParseResult {
    /** A result representing "FOO" data is correctly parsed as an integer. */
    class Success(val value: Int) : FooParseResult()

    /** An error result representing the parsing regex implementation is incorrect. */
    object InvalidRegexError : FooParseResult()
 }

 private val FOO_FORMAT_REGEX: Regex = """foo:(\d{1,6})""".toRegex()

What issues does this code have?

No use crying over spilt milk

How to notify errors depends on how the caller handles those errors.

The above code includes the following two types of errors:

  • An invalid format of the string provided as an argument
  • Incorrect regex implementation

Generally, treating arguments provided by the caller as untrustworthy leads to more robust code. This is because those arguments might come from user input or external systems. Assuming that "invalid arguments are commonly provided" is necessary for implementing error handling.

On the other hand, mistakes in regular expression implementation are errors confined to the parseFooValue function. The caller doesn't need to worry about these errors, which should be considered "irrecoverable."

With this in mind, let's examine the ways in which each error is represented. Errors related to argument format are represented by IllegalArgumentException, and mistakes in regular expression implementation are represented by an object within a sealed class. Logic errors such as IllegalArgumentException should not be caught in most cases. Instead, the logic itself should be corrected. However, despite being logic errors, mistakes in regular expressions are forced to be handled by the caller through the use of a sealed class, essentially compelling the handling of such errors. Therefore, there is a mismatch between how errors should be handled and how they are represented.

(Un)recoverability Level

How to represent errors vary widely, though it depends on the programming language and the processing system. When deciding which error representation to use, you can use the recoverability of the error as a reference.

Recoverable
↑
┃ 0 Default values
┃ 1 Simple domain errors
┃ 2 Sum types + Error values, nullable error values (, or multiple return values)
┃ 3 Checked exceptions
┃ 4 Unchecked exceptions
┃ 5 Uncatchable errors
↓
Irrecoverable

0. Default value

Example: "", 0 (additive value), 1 (coefficient), Int.MIN_VALUE, [], null object pattern

Default values are used when the caller doesn't need to distinguish whether an error has occurred or not.

// If some error happens, `getUsers` returns empty
val userList = userProvider.getUsers()
userList.forEach { messageSender.send("You are ${it.name}.") }

1. Simple domain error

Example: null/nil/undefined, Optional/Maybe, false

A simple domain error is used when the occurrence of an error needs to be known, but not its details. A typical example is when an early return is applied.

// If some error happens `getUser` returns null.
val user = userProvider.getUser(id)
    ?: return
dialog.showWithMessage("You are ${user.name}.")

If there is no return value in the normal course of action, a boolean value indicating whether an error has occurred is often used. In many cases, false indicates that an error has occurred. (However, please note that in C, 0 often indicates a successful completion.)

Where possible, you should use type-safe simple domain errors (such as Kotlin's null or Scala's Option.empty). If type-safe simple domain errors are available, you shouldn't use special error values like -1 or 0xDEADBEEF.

2. A sum type containing an error value, nullable error value (or multiple return values)

Example: Either, Result, sealed class, associated value, nullable error value

A sum type is used when different values are necessary for normal and error cases. This is effective when the caller is required to process an error differently according to the type of the error.

sealed interface Response {
    class Success(val value: Value): Response
    class Error(val errorType: ErrorType): Response
}
enum class ErrorType { ... }

If no value is required in the normal case, it's also possible to return a nullable error value. In this case, null means the normal case. However, if a nullable error value may lead to a misunderstanding, it's better to define a sum type.

As a concept similar to this, some languages (such as Go and Python) adopt multiple return values.

3. Checked exception

Example: Java's Exception, Swift's Error

Theoretically, a checked exception can be considered equivalent to using a sum type as a return value. However, in practical implementations (in Java or Swift), a strict type handling is harder than when using a sum type. In particular, Exception in Java has many pieces of information called stack information. Accordingly, in practice, you should use checked exceptions for types closer to being unrecoverable than a sum type.

4. Unchecked exception

Example: Java's RuntimeException

You should use an unchecked exception for an error "unrecoverable in most cases", such as processing system errors and logic errors. This is because in comparison to the above four, it is possible to overlook the possibility of errors occurring in the first place.

However, in languages without sum types or checked exceptions, unchecked exceptions are sometimes used as recoverable errors.

5. Uncatchable error

Example: Swift's fatalError()

An uncatchable error is used in an almost same way as an unchecked error, but realizes a "fail fast" more strictly.

You can't return spilt milk back into the bottle

The above sample code can be improved as follows:

  • Return null for invalid input
  • Use unchecked exceptions for mistakes in regex implementation
    • Use get ([]) instead of getOrNull for retrieving string groups
    • Use toInt instead of toIntOrNull
/**
 * Returns an integer parsed from the given "FOO" data string.
 *
 * The expected format is: "foo:(1-6 digit non-negative integer)".
 * For example, "foo:00" and "foo:123456"
 are valid while "foo : 1", "foo,2", "foo:-1" are not.
 *
 * This returns null if the given text format is invalid.
 */
fun parseFooValue(inputText: String): Int? {
    val matchResult = FOO_FORMAT_REGEX.matchEntire(inputText)
        ?: return null
    return matchResult.groupValues[1].toInt()
}

Whether an error is recoverable or not generally depends on the caller's code and the scope where the error is handled. For example, when an error occurs during query processing, it may generally be handled as "unrecoverable from the perspective of the query processing logic, but recoverable from the perspective of the server process." If it cannot be determined whether an error is recoverable or not unless the caller's code is determined, it may be necessary to consider returning the error in an easy-to-handle manner once and then converting it to a different error at the caller.


One-sentence summary: Use an appropriate way of representing an error according to its recoverability.

Keywords:recoverable error,logic error,exception