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 23: Too early to return

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

This article is the latest installment of our weekly series "Improving code quality". For more information about the Weekly Report, please see the first article.

Too early to return

Early return is an important technique for clarifying the flow of operations. By excluding error cases first, it becomes easier to write code that focuses on the "main purpose of the function". It also makes the relationship between error conditions and their handling logic clearer. (For more details, refer to slides 39 to 48 from my Code readability: Session 5 presentation.)

The following code takes a list of user IDs and returns a list of usernames. Early return is applied here, but is there a problem?

fun getUserNames(userIds: List<UserId>): List<String> {
    if (userIds.isEmpty()) {
        return emptyList()
    }
    if (userIds.size == 1) {
        val userData = repository.getUserData(userIds[0])
        return listOf(userData.name)
    }

    return userIds.asSequence()
        .map(repository::getUserData)
        .map(UserData::name)
        .toList()
}

Include rather than exclude

Early return should not be applied unconditionally in all cases. Whether to apply early return depends on how different the processing is between error cases and normal cases. If the processing is the same for both, it may be easier to simplify the code by treating the "error" as a normal case. In this case, by considering userIds.isEmpty() and userIds.size == 1 as normal cases, the processing can be unified.

fun getUserNames(userIds: List<UserId>): List<String> =
    userIds.asSequence()
        .map(repository::getUserData)
        .map(UserData::name)
        .toList()

(However, this code performs worse for size == 0 or size == 1 because it creates a Sequence instance and a new List instance with Sequence.toList in both cases.)

Below are cases where it might be better to treat them as normal cases.

Scanning empty collections

Higher-order functions like map, filter, and forEach that scan collections are effective even for empty collections in many languages and libraries. Functions like sum and reduce that perform folding often behave naturally with empty collections.

val empty: List<Int> = emptyListOf()
empty.all { false } // => true
empty.any { true } // => false
empty.sum() // 0

null

Some languages have an operator called the safe call operator ?., which is useful for treating null (nil, undefined) as a normal case. For example, the early return with == null can be included in the normal case with ?..

// Before
fun function(foo: Foo?): Bar? {
    if (value == null) {
        return null
    }
    return value.toBar()
}

// After
fun function(foo: Foo?): Bar? =
    value?.toBar()

Additionally, some languages allow using the Elvis operator ?: or the null coalescing operator ?? to fall back to a default value. Falling back to a default value with ?: 0 or orEmpty is a good option, but be careful not to re-branch with the converted default value (refer to When there is null, there is smoke).

Out-of-range arrays or lists

If there is code that checks the index range first, you can use functions like getOrNull or getOrElse instead of returning early when out of range. Using getOrNull can address the null issue mentioned above.

val fooList: List<Foo?> = ...

// Before
fun function(index: Int): Foo? {
    if (index < 0 || fooList.size <= index) {
        return null
    }
    return fooList[index]
}

// After
fun function(index: Int): Foo? =
    fooList.getOrNull(index)

If there is no equivalent to getOrNull or getOrElse in the standard library, it might be good to define them as generic functions.

Properties dependent on other properties

There are situations where "a property only has meaning when another property has a specific value". For example, consider a UI element called textView, where textView.isVisible indicates whether it is visible, and textView.text indicates the text content. In this case, text only has meaning when isVisible is true.

In the following code, if someText is an empty string, textView is hidden, and text is not updated.

if (someText.isEmpty()) {
    textView.isVisible = false
    return
}
textView.isVisible = true
textView.text = someText

Such early returns to exclude meaningless assignments can sometimes be removed. When isVisible is false, it might not matter what value text has. In that case, you can unify the processing for true and false as follows:

textView.isVisible = someText.isNotEmpty()
textView.text = someText

Exceptions in a series of function calls

When exceptions occur at various points in a function, applying early return can make the code cumbersome.

For example, in the following code, someData is converted to anotherData and then to yetAnotherData, but exceptions may occur along the way, and in such cases, it returns.

sealed class FooResult {
    class Success(val fooData: FooData): FooResult()
    class Error(val errorType: ErrorType): FooResult()
}
enum class ErrorType { SOME, ANOTHER, YET_ANOTHER }

fun getFooData(): FooResult {
    val someData = try {
        apiClient.getSomeData()
    } catch (_: SomeException) {
        return Error(ErrorType.SOME)
    }
    val anotherData = try {
        unreliableRepository.getAnotherData(someData)
    } catch (_: AnotherException) {
        return Error(ErrorType.ANOTHER)
    }

    return try {
        unreliableRepository.getYetAnotherData(anotherData)
    } catch (_: YetAnotherException) {
        Error(ErrorType.YET_ANOTHER)
    }
}

In such cases, using a function like flatMap that applies operations only to Success can improve readability. First, define flatMap in FooResult.

sealed class FooResult<T> {
    class Success<T>(val value: T): FooResult<T>()
    class Error<T>(val errorType: ErrorType): FooResult<T>()

    @Suppress("UNCHECKED_CAST") // ...
    fun <U> flatMap(action: (T) -> FooResult<U>): FooResult<U> = when (this) {
        is Success -> action(value)
        is Error -> this as FooResult<U>
    }
}

Next, define helper functions like getSomeData to convert exceptions to FooResult.

private fun getSomeData(): FooResult<SomeData> = try {
    FooResult.Success(apiClient.getSomeData())
} catch (_: SomeException) {
    FooResult.Error(ErrorType.SOME)
}

By doing this, FooResult.Error converted from exceptions can also be treated as a normal case, making the function flow clearer.

fun getFooData(): FooResult = getSomeData()
    .flatMap(::toAnotherData)
    .flatMap(::toYetAnotherData)

Summary

Before using early return, consider whether error cases and normal cases can be integrated.

Keywords: early return, error case, function flow

List of articles on code quality improvement techniques