When calling a function which returns data, there is often the need to handle errors which might occur. This post details how to make use of Kotlin’s sealed classes to return one object which can represent the data you expected or an error.
Let’s imagine we have a function which accepts some data as a parameter and returns us the parsed result. It relies on a regex to help with the parsing; given we don’t control the input, that regex might fail to match depending on the string provided to the function.
private fun parse(url: String): ParsedData {
val result = URL_PARSE_REGEX.find(url)
if (result == null) {
// What do we do here? Throw an exception? Return null?
}
val mimeType = result.groupValues[2]
val data = result.groupValues[4]
return ParsedData(data, mimeType)
}
data class ParsedData(
val data: String,
val mimeType: String
)
If the regex fails to match anything, result
will be null.
if (result == null) {
// What do we do here? Throw an Exception? Return null?
}
Note this code 👆. If during the parsing, we decide we can’t continue, we have to find a way to break out of the function.
Potential Solutions (Don’t do these)
Throw an Exception
We might decide one way to achieve this is to throw an Exception
.
if (result == null) {
throw IllegalArgumentException()
}
However, forcing exception handling onto the caller of this function is pretty heavyweight. Even runtime exceptions are still a burden to handle, and not finding a match on a regex doesn’t sound all that exceptional so this probably isn’t the right fit.
Return null
We might decide instead to return null when the regex match fails.
if (result == null) {
return null
}
Now the caller of this function can perform a null check and use the value being null as an indication that the error flow happened. But we’d also have to define the function as returning ParsedData?
now and we have no way of providing details as to what went wrong. All the caller knows is that something didn’t work.
Introduce a wrapper object
We could introduce a new class solely responsible for wrapping both the desired data or an error. Since in this scenario, data
and errorMessage
are mutually exclusive, we have to make both nullable.
class ResultWrapper (
val data: ParsedData? = null,
val errorMessage: String? = null
)
We then interrogate the wrapper to determine if an error happened; in this case by checking if result.errorMessage
is null or not. Even after determining an error didn’t happen, we still have the annoyance of our real data being nullable by necessity, so we have to force unwrap it using !!
.
val result = parse(url)
if (result.errorMessage != null) {
// error occurred - log error message etc...
} else {
// happy path - use the data. But result.data is nullable 🙄
val mimeType = result.data!!.mimeType
}
Using a wrapper object which might contain the data or an error is better than returning null or throwing an exception, but it is still a little clunky.
Better Solution (Do this)
Sealed Class
Sealed classes are used for representing restricted class hierarchies, when a value can have one of the types from a limited set, but cannot have any other type.
That definition of a sealed class fits very well with this scenario. We want to be able to return an object from a function which:
- Can represent the data we asked for, or
- Can represent some sort of error
We don’t want to return just any type of object as we want some compile-time safety around what is returned. As with the basic wrapper approach outlined above, we can change the parse
function so that it doesn’t return the ParsedData
directly; this time it returns a sealed class
.
sealed class ParseResult
data class Error(val errorMessage: String) : ParseResult()
data class ParsedData(
val data: String,
val mimeType: String) : ParseResult()
By defining a sealed class we can then start defining other classes which inherit from it. In the example above, we have declared both an Error
class and the ParsedData
class which holds the data we were trying to obtain. You could even go further and return many different types of error, each of which could have their own fields.
We can modify the parse
function now so that it:
- Declares
ParseResult
sealed class as its return type - Returns
ParsedData
when the parsing succeeds - Returns
Error
when the parsing fails
Our new parse
function now looks like this 👇
private fun parse(url: String): ParseResult {
val result = URL_PARSE_REGEX.find(url)
if (result == null) {
return ParseResult.Error("No match found")
}
val mimeType = result.groupValues[2]
val data = result.groupValues[4]
return ParseResult.ParsedData(data, mimeType)
}
At this point, we haven’t gained much over the basic wrapper approach from before. But when we want to use the ParseResult
object, that’s when we see the advantages.
Sealed Classes and when()
Sealed classes pair nicely with the when expression. This is much like Java’s Switch operator except the Kotlin version automatically casts the value for you inside each of the when blocks.
val result = parse(url)
when (result) {
is ParseResult.ParsedData -> analyzeMimeType(result.mimeType)
is ParseResult.Error -> log(result.errorMessage)
}
There’s no need to cast to ParsedData
before trying to access mimeType
, and there’s no need to cast to Error
before trying to access errorMessage
. 🙏
If you returned the result of the when
expression, the compiler would then start highlighting any ParseResult
subclasses that you haven’t handled; providing further compile time safety against forgetting to handle new types of the sealed class.
Sealed or Abstract
You could define ParseResult
as abstract
instead of sealed
and you might be wondering why sealed
is still better. While abstract
would work in this example above, it would also allow something potentially undesirable.
Along with the ParsedData
and Error
subclasses we’ve defined here, having ParseResult
as abstract
would also allow subclasses to be defined anywhere in the project.
Whereas with a sealed
class for ParseResult
you have limited scope in which to define subclasses.
A sealed class can have subclasses, but all of them must be declared in the same file as the sealed class itself.
This helps reduce bugs by ensuring all the different types of possible ParseResult
have to be defined in the same class.
Conclusion
When calling a function which returns data, there is often the need to also handle errors which might occur. By using a Kotlin sealed
class, you can purposefully restrict the types of data you can return from any given function; limiting the return types to different types of data or of one or more error types.
The different classes returned can each hold completely different data from each other. And yet each distinct type can be handled elegantly when combined with Kotlin’s when
expression.
Image Attribution: Photo by Roger Brendhagen on Unsplash