15
$\begingroup$

There seem to be two main ways that languages handle the same concept:

  • Nullable types, like Kotlin, C#, and many other OOP languages
  • Options, like Rust and Haskell, which are wrappers around a type that can either be Some(...) or None (or equivalent names)

While these serve similar purposes, they do it in different ways. Having used and liked languages with both of these, what are the pros and cons of each?

$\endgroup$

7 Answers 7

16
$\begingroup$

Advantages of Option types:

  • No need to implement flow typing, which can be hard to get right
  • Can be used with primitive types (Int? would require boxing in a language with nullable types, removing the performance benefits)
  • Can be nested - A function can accept an Option<T>, where T may itself be Option<Foo>. With boxing, it's possible to distinguish between a Some(None) and a None in this case. This isn't possible with nullable types (Int?? isn't a thing in Kotlin).
  • Can implement interfaces such as Iterable or typeclasses such as Monad

Advantages of nullable types:

  • Syntactically lighter - It can be cumbersome to write Option<T> and Some(x) everywhere, while in a language like Kotlin, you only need to write T? and x. Note: alephalpha pointed out in the comments that this isn't always true - the syntax for Zig's optional types looks similar to that of nullable types in other languages.
  • With flow typing, once you check that a value is not null, you don't need to check again (in most circumstances). That means you can do if (x != null) x.foo(x.bar) rather than if (x.nonEmpty) x.get.foo(x.get.bar) or x.map(y => y.foo(y.bar))
  • No boxing means that they take up less space and there's a performance benefit since you don't need to unwrap a Some/Just every time you want to access the value inside. As Bbrk24 mentioned in the comments, though, languages such as Rust and Swift can implement Options without boxing or with only a single bit to indicate whether the Option is empty, so this point only applies to certain languages. For example, Scala boxes its Options (although it's entirely possible to have nestable unboxed Options even in a language that doesn't natively support them, depending on your language's type system).
$\endgroup$
7
  • $\begingroup$ The lighter syntax of nullable types is why I implemented them over option types in my language $\endgroup$
    – Seggan
    Commented May 17, 2023 at 16:11
  • 2
    $\begingroup$ @Bbrk24: GHC Haskell doesn’t have any special representation for Maybe T vs. T. A lazy Maybe value (i.e., not a thunk) is a pointer to a tagged union, which is either Nothing or Just (x :: T) where x may be a thunk. As an optimisation, we can tell whether it’s Nothing or Just without dereferencing that pointer, by examining its tag bits, since the data type has a small number of constructors. A strict Maybe (from the strict package) contains a pointer instead of a thunk in the Just case. $\endgroup$
    – Jon Purdy
    Commented May 17, 2023 at 16:12
  • 5
    $\begingroup$ Either options or nullable types can be implemented with(out) boxes as an optimization. The intuitive implementation of a type isn't an intrinsic property of that type, so I'd avoid designing a language around it. $\endgroup$ Commented May 17, 2023 at 16:17
  • $\begingroup$ Zig's optional type uses a light syntax. $\endgroup$
    – alephalpha
    Commented Jun 14 at 4:06
  • $\begingroup$ @alephalpha Correct me if I'm wrong, but aren't Zig's optionals like Kotlin's nullable types, rather than what I mean by "optional" in this answer? $\endgroup$
    – user
    Commented yesterday
11
$\begingroup$

Special-casing

Another factor to consider is how much of a special case your approach to nullability will require. Option almost never requires any special handling in the compiler, as it can be defined as a simple datatype within the confines of the language.

On the other hand, nullable types can sometimes require a new specialized type within the type system. If Kotlin didn't have nullable types and everything was non-null forever, we wouldn't be able to ergonomically add nullable types to the language without modifying the compiler. Note that this isn't true in languages with union types, as "nullable string" can be written in a language like Typescript simply as string | null.

$\endgroup$
3
  • $\begingroup$ It’s worth noting that special casing is not that heavy to implement. In my language with nullable types the actual handling for them is just over 2 lines of code $\endgroup$
    – Seggan
    Commented May 17, 2023 at 16:13
  • $\begingroup$ @Seggan Does your language have flow typing, though? That's a lot more complicated (although flow typing is certainly worth it) $\endgroup$
    – user
    Commented May 17, 2023 at 16:20
  • $\begingroup$ @user yes it will. I don’t see how it’s that different from doing SSA conversions tho $\endgroup$
    – Seggan
    Commented May 17, 2023 at 16:33
8
$\begingroup$

Option is an algebraic data type (ADT), so if your language already supports ADTs, you can implement Option in the standard library, whereas nullable types are implemented as a separate feature. Option may be passed as a type parameter because it allows nesting (e.g. Option<Option<T>>), whereas for nullable types, that is not necessarily the case.

On the other hand, Options usually have to be unwrapped explicitly using pattern matching, so nullable types may be more syntactically lightweight to use. This can be mitigated by adding syntactic sugar like Rust's ? operator.

$\endgroup$
3
  • $\begingroup$ Why would you ever need a nested option type? $\endgroup$
    – Seggan
    Commented May 17, 2023 at 15:56
  • 6
    $\begingroup$ @Seggan Suppose you have some generic function working on lists of any type T, and you return a list of Option<T>s. What if T = Option<Int>? $\endgroup$
    – user
    Commented May 17, 2023 at 15:57
  • $\begingroup$ True. I haven’t thought of that $\endgroup$
    – Seggan
    Commented May 17, 2023 at 16:00
5
$\begingroup$

Apart from @user's answer, there's usually the following convention for nullable types:

a?.b()?.c()

This it is intuitive to translate the above into

if (a != null) {
  b = a.b()
  if (b != null) {
    c = b.c()
    ....

However, it may also be the case that b() returns non-null, so the second null check is unnecessary. However, we may not write a?.b().c() because a?.b() as an expression is nullable!

This is not a problem in chained Option operations.

$\endgroup$
1
  • 5
    $\begingroup$ In some languages like C#, you can do a?.b().c(), as the ?. eats up the whole rest of the expression. $\endgroup$
    – naffetS
    Commented May 17, 2023 at 23:11
5
$\begingroup$

Nullable types are at the same level of indirection

Suppose I have the following Kotlin code:

fun foo(): Bar? {...}

val x = foo()
if (x != null) {
    doStuff(x)
}

If I later decide that Bar doesn't need to be nullable (perhaps because an external API changed) then I can simply remove the ? from the return type. While the users of my function now have no reason to do the null check, their code still compiles, albeit with a "redundant code" warning. If Kotlin had opted to use an Optional type instead, the users of my function can't recompile their code without changing it.

Credit to this HN post I saw yesterday for the basic idea.

$\endgroup$
3
  • $\begingroup$ Swift allows implicit wrapping, so 5 == nil is equivalent to .some(5) == nil. You still get the redundant check warning, but it’s legal (unlike in Rust, tmk). $\endgroup$
    – Bbrk24
    Commented May 23, 2023 at 16:58
  • $\begingroup$ What I meant by this is that in a language like Java where everything is a reference type, Optional is just a pointer to a pointer, which is totally unnecessary when you could just use a single pointer or null. $\endgroup$
    – Bbrk24
    Commented May 23, 2023 at 17:12
  • $\begingroup$ @Bbrk24 feel free to add as an answer then $\endgroup$
    – Seggan
    Commented May 23, 2023 at 17:13
5
$\begingroup$

Monad

It should be noted that usage of option types can be simplified by using monad and other functional programming facilities, and they do not need to be unwrapped everywhere.

For example, if you have two option values mx and my of Maybe Integer, you don't have to unwrap them and can add them using the do notation with monads:

do x <- mx
   y <- my
   pure (x + y)

If one of mx and my is Nothing, the return value will be Nothing. There is no need to case analyze whether mx and my are empty or not.

With the Applicative interface, one can also add the option values "directly":

pure (+) <*> mx <*> my

So, the syntactic burden of option types isn't necessarily heavy.

NULL

On the other hand, NULL values have many problems as already mentioned in other answers.

One good example is the SQL language. There is much ambiguity as to how NULLs should be handled or even interpreted. To this day, there is still inconsistencies among SQL aggregate functions as to whether they should return a NULL if the input is empty. Some return NULL (e.g. array_agg()) while others do not (e.g. count()).

Similarly, in practice, some users enter an empty string '' while others enter a NULL when a string field has no data.

$\endgroup$
4
$\begingroup$

Operators

Usually, nullable types and Options will provide special operators, like the Elvis (?:), for checking if they're null/asserting that they're not null/etc. Option types allow this to be implemented in a consistent manner within the language and easily overridden by other types, since they can just be implemented as operator functions using whatever syntax the language uses for those.

$\endgroup$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .