The original article was published on July 4, 2024.
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.
Return from the abyss
Early return is an effective technique to separate cases where a function achieves its main purpose (happy path) from cases where it does not (unhappy path), making the function flow easier to understand. However, careful consideration is needed on how to apply early return.
In the following code, return
is used for unexpected cases of messageData.mainMimeType
and failed saving of messageData.content
, but it makes the code harder to read. How can it be improved?
fun ...(...) {
try {
val messageType = if (messageData.isParsed) {
messageData.messageType
} else {
when (messageData.mainMimeType) {
"image" -> MessageType.IMAGE
"video" -> MessageType.VIDEO
else -> return false
}
}
val saveResult = saveContent(messageData.content, messageType) // IOException may be thrown
if (!saveResult.isSuccessful) {
return false
}
return true
} catch (_: IOException) {
return false
}
}
Create a clear return path
The return
in the previous function is hard to understand for the following two reasons:
- There are
return
statements from deeply nested places or parts of branches (e.g., the firstreturn false
is insidetry
,if
, andwhen
, and the conditions to reach there are not immediately clear) - The
return
does not significantly contribute to separating the happy path from the unhappy path (e.g., thereturn false
for!saveResult.isSuccessful
has no subsequent actions)
When using early return, it is important to place return
in prominent locations, make conditions clear, and highlight the happy path.
For example, by doing the following, it becomes clear that "early return occurs when messageType
becomes null". Also, by looking at the last line, it is easy to understand that true
is returned when the message is saved successfully.
fun ...(...) {
val messageType = messageData.messageType.takeIf { messageData.isParsed }
?: MAIN_MIME_TO_TYPE_MAP[messageData.mainMimeType]
?: return false
val saveResult = try {
saveContent(messageData.content, messageType)
} catch (_: IOException) {
SaveResult.Error()
}
return saveResult.isSuccessful
}
companion object {
val MAIN_MIME_TO_TYPE_MAP: Map<String, MessageType> = mapOf(
"image" to MessageType.IMAGE,
"video" to MessageType.VIDEO
)
}
Especially when using multi-branch structures like when
or switch
, writing code that returns for only some conditions can lead to overlooking the early return itself. For example, in the code converting Foo
to Bar
below, you might miss the return
in the INVALID
case.
fun ...(foo: Foo) {
val bar = when (foo) {
Foo.FIRST -> Bar.FIRST
Foo.SECOND -> Bar.SECOND
Foo.THIRD -> Bar.THIRD
Foo.INVALID -> return
}
...
}
This issue can be resolved by separating multi-branch structures and early returns. In the improvement proposal below, the code converting Foo
to Bar
is extracted as a function.
fun ...(foo: Foo) {
val bar = toBar(foo)
?: return
...
}
private fun toBar(foo: Foo): Bar? = when (foo) {
Foo.FIRST -> Bar.FIRST
Foo.SECOND -> Bar.SECOND
Foo.THIRD -> Bar.THIRD
Foo.INVALID -> null
}
This emphasizes that "early return occurs when Foo
cannot be converted to Bar
".
For more on early return, refer to the "Code Readability" presentation (slides 39-48).
In a nutshell
When using early return, make its presence, conditions, and the happy path stand out.
Keywords: early return
, conditional branch
, function flow