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 16: Where there is null, there is smoke

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.

Where there is null, there is smoke

The Null Object pattern is a design pattern that uses an object representing "empty" or "invalid" instead of values like null, nil, or undefined. There are several reasons to use this pattern, one of the typical ones being to "convert errors into fallback values".

Assume there is a function TeamDao.selectMemberIds(TeamId): List<Int>?. This function usually returns a list of team member IDs, but if given a non-existent team ID, it returns null. In the following TeamModelRepository.getMemberIds(TeamId), when TeamDao.selectMemberIds returns null, it falls back to an empty list using .orEmpty().

class TeamModelRepository(private val dao: TeamDao) {
    fun getMemberIds(teamId: TeamId): List<Int> =
        dao.selectMemberIds(teamId).orEmpty()
}

By using this pattern appropriately, you can simplify the caller's code. A notable example is iterating over collections like List. The following code calls TeamModelRepository.getMemberIds, and since it doesn't need to handle null, the code is simplified.

teamModelRepository.getMemberIds(teamId).asSequence()
    .map(userModelRepository::getUserModel)
    .map(UserModel::getName)
    .forEach { userName -> sendMessage("Hello, $userName") }

The Null Object pattern can be used not only for generic types like List but also for application-specific data models.

The following UserModel defines an instance called INVALID as a Null Object.

class UserModel(
    val id: Int,
    val accountName: String,
    val nickName: String,
    val emailAddress: String
) {
    val isInvalid: Boolean
        get() = this == INVALID

    companion object {
        val INVALID = UserModel(0, "", "", "")
    }
}

This UserModel is used as follows:

class UserModelRepository(private val dao: UserDao) {
    fun getUserModel(userId: Int): UserModel =
        dao.selectUser(userId) ?: UserModel.INVALID
}

// On the caller side
fun caller(userId: Int) {
    val userModel = userModelRepository.getUserModel(userId)

    if (userModel.isInvalid) {
        ... // Unhappy-path logic such as showing a dialog
        return
    }

    ...
    // Happy-path logic, such as updating profile UI
}

Is there any problem with this code?

Not raising smoke with null

If you need to distinguish between Null Objects and regular objects, not using the Null Object pattern can make your code more robust.

To understand this, we first need to confirm the benefits of the Null Object pattern. One of the benefits is that it makes it easier to integrate edge case or error logic with regular cases. The following code is an example of this.

class ProfileViewData(
    val userName: String,
    val profileImagePath: Path,
    ...
) {
    companion object {
        private val UNKNOWN = ProfileViewData("Unknown user", UNKNOWN_USER_IMAGE_PATH, ...)

        fun fromProfileModel(model: ProfileModel?) {
            if (model == null) {
                return UNKNOWN // Converts null to a null object
            }

            // Happy-path case
            ...
            return ProfileViewData(...)
        }
    }
}

// On the caller side
fun updateProfileView(val profileModel: ProfileModel?) {
    val viewData = ProfileViewData.fromProfileModel(profileModel)

    // Only happy-path case, no unhappy-path case
    nameTextView.text = viewData.userName
    profileImageView.image = loadImage(viewData.profileImagePath)
    ...
}

In contrast, when you need to separate edge case or error logic from regular cases, the Null Object pattern often becomes inappropriate. In the UserModel code snippet, you needed to check userModel.isInvalid. However, if you forget to check isInvalid, the code will still compile, and you won't notice the bug until you run it.

When you need to clearly distinguish between edge cases/errors and regular cases, it is preferable to use statically verified types. Typical examples are Kotlin's null, Swift's nil, and Optional or Maybe in other languages. Using different types to distinguish errors is also effective in dynamically typed languages. It is easier to notice bugs if a runtime error occurs rather than executing code that should not be executed.

When using the Null Object pattern, it is best to limit it to the following situations:

  1. There is no need to distinguish between edge cases/errors and regular cases
  2. There are multiple candidates for the "error" value, and it is not possible to statically verify which one to use
    • Example: List<T>? with null and an empty list. In this case, it is often better to change it to List<T> and use only the empty list as the error value.

Aside: "identity" and "equivalence"

"Identity" means "being the same object", while "equivalence" means "being the same value (even if they are different objects)".

When using the Null Object pattern, you need to be aware of the difference between identity and equivalence. UserModel.isInvalid is implemented with the expression ... == INVALID, and UserModel does not have a custom equals implementation. In this situation, the result of UserModel(0, "", "", "").isInvalid will be false, which can lead to unexpected bugs.


Summary in a nutshell

If you need to distinguish error values, it is often better not to use the Null Object pattern.

Keywords: error value, type safeness, null object pattern