Why nullable types & Option?
If you have worked with Java at all in the past, you have likely come across a NullPointerException
at some time (other languages will throw similarly named errors in such a case).
Usually, this happens because some method returns null
when you weren't expecting it and, thus, isn't dealing with that possibility in your client code.
A value of null
is often abused to represent an absent optional value. Kotlin already solves the problem by getting rid of null
values altogether and providing its own unique syntax Null-safety machinery based on ?
.
Since Kotlin already has nullable types, why do we need Arrow's Option
type? There are only a few cases where you should use Option
instead of nullable types, and one is the nested nullability problem. Let's see an example:
We write a small firstOrElse
function, which should return the list's first element or the default value if the list is empty.
fun <A> List<A>.firstOrElse(default: () -> A): A = firstOrNull() ?: default()
fun example() {
emptyList<Int?>().firstOrElse { -1 } shouldBe -1
listOf(1, null, 3).firstOrElse { -1 } shouldBe 1
}
Running this code with an emptyList
or a non-empty list seems to work as expected.
We get an unexpected result if we run this function with a list that contains null
as the first value.
fun example() {
listOf(null, 2, 3).firstOrElse { -1 } shouldBe null
}
Now we're executing the function on a list that isNotEmpty
, so we expect it to return the first element of value null
.
Instead, it returns -1
, the default value we specified in case the list isEmpty
!
Exception in thread "main" java.lang.AssertionError: Expected null but actual was -1
This is known as the nested nullability problem, which can be solved using Option
instead of nullable types.
So let's analyze what is going wrong here and how we can fix it. When we look at the implementation of our firstOrElse
function,
we see that we're using firstOrNull
to get the first element of the list, and if that is null
, we return the default value.
However, our generic parameter of Any
has an upperbound of Any?
, so we can pass in a list of nullable values.
This means that firstOrNull
can return null
if the first element of the list is null
, and we're not handling that case.
fun <A> List<A>.firstOrElse(default: () -> A): A = firstOrNull() ?: default()
We can solve this in two ways. One is by restricting A
to have an upperbound of Any
instead,
but then we limit this function to only work with non-nullable types.
fun <A : Any> List<A>.firstOrElse(default: () -> A): A = firstOrNull() ?: default()
Our previous examples of List<Int?>
would not even compile in that case, so this is not a good solution.
Instead, we could use firstOrNone
, and then we can handle the case where the first element is null
:
fun <A> List<A>.firstOrElse(default: () -> A): A =
when(val option = firstOrNone()) {
is Some -> option.value
None -> default()
}
If we rerun our previous examples, they all behave as expected since we can rely on None
to detect the case where the list is empty.
fun example() {
emptyList<Int?>().firstOrElse { -1 } shouldBe -1
listOf(1, null, 3).firstOrElse { -1 } shouldBe 1
listOf(null, 2, 3).firstOrElse { -1 } shouldBe null
}
Sometimes you might still want to use Option
instead of nullable types, even when you're not the author of these generic functions.
Some libraries such as RxJava and Project Reactor don't support nullable types in all their APIs.
If you still need to work with null
in combination with generic APIs that don't allow nullable types, you can use Option
to work around this problem.
Arrow also provides special DSL syntax for nullable & Option
types
Working with Option
Arrow offers a special DSL syntax for all of its types and provides it for nullable types. So let's review both below.
Before we get started, we need to know how to construct an Option
from a (nullable) value and vice versa.
Option<A>
is a container for an optional value of type A
. If the value of type A
is present, the Option<A>
is an instance of Some<A>
, containing the current value of type A
. If the value is absent, the Option<A>
is the object None
.
And we have four constructors available to create an Option<A>
, their regular class
constructors, and two extension functions that return Option<A>
.
val some: Some<String> = Some("I am wrapped in something")
val none: None = None
val optionA: Option<String> = "I am wrapped in something".some()
val optionB: Option<String> = none<String>()
fun example() {
some shouldBe optionA
none shouldBe optionB
}
Creating a Option<A>
from a nullable type A?
can be helpful when we need to lift nullable values into Option. This can be done with the Option.fromNullable
function or the A?.toOption()
extension function.
fun example() {
val some: Option<String> = Option.fromNullable("Nullable string")
val none: Option<String> = Option.fromNullable(null)
"Nullable string".toOption() shouldBe some
null.toOption<String>() shouldBe none
}
If A?
is null, you should explicitly use the Some
or .some()
constructor.
Otherwise, you will get a None
instead of a Some
due to the nested nullable problem.
fun example() {
val some: Option<String?> = Some(null)
val none: Option<String?> = Option.fromNullable(null)
some shouldBe null.some()
none shouldBe None
}
Extracting values from Option
So now that we know how to construct Option
values, how can we extract the value from it?
The easiest way to extract the String
value from the Option
would be to turn it into a nullable type using getOrNull
and work with it as we would typically do with nullable types.
fun example() {
Some("Found value").getOrNull() shouldBe "Found value"
None.getOrNull() shouldBe null
}
Another way would be to provide a default value using getOrElse
. This is similar to the ?:
operator in Kotlin, but instead of giving a default value for null
, we provide a default value for None
.
In the example below, we provide a default value of "No value"
when the Option
is None
.
fun example() {
Some( "Found value").getOrElse { "No value" } shouldBe "Found value"
None.getOrElse { "No value" } shouldBe "No value"
}
Since Option
is modeled as a sealed class
, we can use exhaustive when
statements to pattern match on the possible cases.
fun example() {
when(val value = 20.some()) {
is Some -> value.value shouldBe 20
None -> fail("$value should not be None")
}
when(val value = none<Int>()) {
is Some -> fail("$value should not be Some")
None -> value shouldBe None
}
}
Option & nullable DSL
Now that we know how to construct Option
values and turn Option
back into regular (nullable) values,
let's see how we can use the Option
and nullable DSL to work with Option
& nullable values in an imperative way.
When working with nullable types, we often need to check if the value is null
or not and then do something with it. We typically do that by using ?.let { }
, but this quickly results in a lot of nested ?.let { }
blocks.
Arrow offers bind()
and ensureNotNull
to get rid of this issue, so let's look at an example and some other interesting functions that Arrow provides in its DSL.
Imagine we have a User
domain class that has nullable email address, and we want to find a user by their id and then email them.
@JvmInline value class UserId(val value: Int)
data class User(val id: UserId, val email: Email?)
fun QueryParameters.userId(): UserId? = get("userId")?.toIntOrNull()?.let { UserId(it) }
fun findUserById(id: UserId): User? = TODO()
fun sendEmail(email: Email): SendResult? = TODO()
fun sendEmail(params: QueryParameters): SendResult? =
params.userId()?.let { userId ->
findUserById(userId)?.email?.let { email ->
sendEmail(email)
}
}
There is already quite some nesting going on and quite a lot of ?
, but we can use bind()
and ensureNotNull
to get rid of the nesting.
The nullable
DSL can seamlessly be mixed with Option
by calling bind
on Option
values.
@JvmInline value class UserId(val value: Int)
data class User(val id: UserId, val email: Email?)
fun QueryParameters.userId(): UserId? = get("userId")?.toIntOrNull()?.let { UserId(it) }
fun findUserById(id: UserId): Option<User> = TODO()
fun sendEmail(email: Email): SendResult? = TODO()
fun sendEmail(params: QueryParameters): SendResult? = nullable {
val userId = ensureNotNull(params.userId())
val user = findUserById(userId).bind()
val email = user.email.bind()
sendEmail(email)
}
Similarly, this same pattern applies to Option
and other data types such as Either
, which is covered in other sections.
The Option
DSL can seamlessly be mixed with nullable types using ensureNotNull
.
Sometimes you need to "forget" the error type if consuming more informative types like Either
. Wrap the .bind()
in ignoreErrors
to make this explicit.
@JvmInline value class UserId(val value: Int)
data class User(val id: UserId, val email: Email?)
fun QueryParameters.userId(): Option<UserId> =
get("userId")?.toIntOrNull()?.let(::UserId).toOption()
fun findUserById(id: UserId): Option<User> = TODO()
fun sendEmail(email: Email): Option<SendResult> = TODO()
fun sendEmail(params: QueryParameters): Option<SendResult> = option {
val userId = params.userId().bind()
val user = findUserById(userId).bind()
val email = ensureNotNull(user.email)
sendEmail(email).bind()
}
Inspecting Option
values
Besides extracting the value from an Option
or sequencing nullable or Option
based logic, we often just need to inspect the values inside it.
With nullable types, we can simply use != null
to inspect the value, but with Option
, we can check whether option has value or not using isSome
and isNone
.
fun example() {
Some(1).isSome() shouldBe true
none<Int>().isNone() shouldBe true
}
The same function exists to check if Some
contains a value that passes a certain predicate. For nullable types, we would use ?.let { } ?: false
.
fun example() {
Some(2).isSome { it % 2 == 0 } shouldBe true
Some(1).isSome { it % 2 == 0 } shouldBe false
none<Int>().isSome { it % 2 == 0 } shouldBe false
}
And, finally, sometimes we just need to execute a side effect if the value is present. For nullable types, we would use ?.also { }
or ?.also { if(it != null) { } }
.
fun example() {
Some(1).onSome { println("I am here: $it") }
none<Int>().onNone { println("I am here") }
none<Int>().onSome { println("I am not here: $it") }
Some(1).onNone { println("I am not here") }
}
I am here: 1
I am here
Conclusion
Typically, when working in Kotlin, you should prefer working with nullable types over Option
as it is more idiomatic.
However, when writing generic code, we sometimes need Option
to avoid the nested nullability issues, or when working with libraries that don't support null values such as Project Reactor or RxJava.
Arrow offers a neat DSL to work with Option
and nullable types in an imperative way, which makes it easy to work with them both in a functional way.
They seamlessly integrate, so you can use whatever you need and prefer when you need it.