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:
- There is no need to distinguish between edge cases/errors and regular cases
- There are multiple candidates for the "error" value, and it is not possible to statically verify which one to use
- Example:
List<T>?
withnull
and an empty list. In this case, it is often better to change it toList<T>
and use only the empty list as the error value.
- Example:
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.