Option type and Null Safety in dart

Sandro Maglione

Sandro Maglione

Mobile development

Option<T> or T?? What is the point of having the Option type when dart has null safety (T?)?

Their purpose is the same: prevent access to a value that may be missing (null).

Why not removing Option from fpdart?

That's a legitimate question. Nonetheless, after much thinking (and writing, which is better to clarify ideas), I conclude that Option<T> and T? are not mutually exclusive.

This post highlights my thinking about this topic:

  • Advantages of T?
  • Advantages of Option<T>
  • Comparison between T? and Option<T>
  • How it would be possible to merge them together
  • Why better to keep them separate

Introduction - Why we ended up with both Option and T??

Let's look back in history.

The Option type in fpdart was introduced on 10 June 2021.

Fun fact: originally Option was called Maybe (from Haskell), then based on feedback from the community it was renamed to Option

At the time Null safety was in its early stages. It was introduced in beta on 19 November 2020, and released in stable on 3 March 2021.

In the initial months after its release, null safety was not ubiquitous as it is today. Many packages did not support it, and many apps were in the process of migrating to it.

At the time, Option was an excellent alternative. What about now?

What makes null safety great

By default, T? is practically the same as T. What the ? adds is a check on possible null values:

int? nullable() => Random().nextBool() ? 10 : null;
 
...
 
 
int noNull = 10;
int? canBeNull = nullable();
 
final noNullIsEven = noNull.isEven; /// `bool`
 
// final canBeNullIsEven = canBeNull.isEven; ⛔️
final canBeNullIsEven = canBeNull?.isEven; /// `bool?`

int can be used even when a function takes a int?. That's because int contains all the same values as int?. int? only counts one more value: null:

String takesNullable(int? nullInt) => "$nullInt";
 
...
 
int noNull = 10;
int? canBeNull = nullable();
 
takesNullable(canBeNull); /// ☑️
takesNullable(noNull); /// ☑️

Because of this, it is possible to access the full API of int even from int?. You just need to add a ?. check when calling a function:

noNull.abs(); /// ☑️
canBeNull?.abs(); /// ☑️

?. is equivalent to canBeNull != null ? canBeNull.abs() : null

Furthermore, there are also some other features that make working with nullable values easy:

/// Null-aware cascade
receiver?..method();
 
/// Null-aware index operator
receiver?[index];
 
/// Null-aware function call (Allowed with or without null safety)
function?.call(arg1, arg2);
 
/// Using null safety `!`
String toString() {
  if (code == 200) return 'OK';
  return 'ERROR $code ${error!.toUpperCase()}';
}
 
/// Using null safety `late`
class Coffee {
  late String _temperature;
 
  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }
 
  String serve() => _temperature + ' coffee';
}

All of these operators makes working with int? extremely convenient.

Compare that with Option:

  • map method to access the not-null value (instead of ?.)
Option<int> optionInt = Option.of(10);
int? nullInt = nullable();
 
nullInt?.abs();
optionInt.map((t) => t.abs());
 
nullInt?.isEven;
optionInt.map((t) => t.isEven);
  • A function that takes int? does not accept Option<int>
String takesNullable(int? nullInt) => "$nullInt";
 
...
 
 
takesNullable(nullInt);
 
/// takesNullable(optionInt); ⛔️
takesNullable(optionInt.toNullable());
  • The type is not aware of checks calling isSome or isNone
String? strNullable = Random().nextBool() ? "string" : null;
Option<String> optionNullable = some("string");
 
if (optionNullable.isSome()) {
  optionIntNullable; /// Still type `Option<int>`, not `Some<int>` 😐
}
 
if (strNullable != null) {
  strNullable; /// This is now `String` 🤝
}

Therefore, Option<T> and T? have a similar functionality, but T? has some language features specifically designed to make it less verbose.

You can read more about all the features designed for null safety in the official documentation

Advantages of the Option type

The main reason that makes Option preferable is chaining methods.

T? is simply a way to declare: "This value can be null".

Option instead is a full class containing a powerful API to compose functions together.

Option (or Maybe, or Optional) is a functional programming type. Functional programming languages use Option instead of null. Option promotes composability

Option gives you a declarative API to easily manipulate its value, regardless if the value is present or not (it's called Monad 👻):

int doSomething(String str) => str.length + 10 * 2;
int doSomethingElse(int number) => number + 10 * 2;
 
...
 
/// Option has methods that makes it more powerful (chain methods) ⛓
String? strNullable = Random().nextBool() ? "string" : null;
Option<String> optionNullable = some("string");
 
/// Declarative API: more readable and composable 🎉
Option<double> optionIntNullable = optionNullable
    .map(doSomething)
    .alt(() => some(20))
    .map(doSomethingElse)
    .flatMap((t) => some(t / 2));
 
/// Not really clear what is going on here 🤔
double? intNullable = (strNullable != null
        ? doSomethingElse(doSomething(strNullable))
        : doSomethingElse(20)) / 2;

These convenience cannot be overlooked. Chaining method like this makes your code more readable, easy to maintain, and type-safe at the same time.

Extension methods: joining Option and T?

Why not adding the same powerful API of Option to T??

Dart has a feature called Extension methods.

This feature allows to add methods to any type, without declaring them inside their class.

This means we could add all the methods of Option to all nullable types. This would look like the following 👇:

/// `fpdart` extension to chain methods on nullable values `T?`
extension FpdartOnNullable<T> on T? {
  B? map<B>(B Function(T t) f) {
    final value = this;
    return value == null ? null : f(value);
  }
 
  B match<B>(B Function() onNull, B Function(T t) onNotNull) {
    final value = this;
    return value == null ? onNull() : onNotNull(value);
  }
 
  ...
}

Now all nullable types have the power of the Option API!

Why extension methods don't work

Wait, there is a problem here 🤔. What is the result of this code below?

List<int>? list = Random().nextBool() ? [1, 2, 3, 4] : null;
list.map((e) => /** What type is `e`? 😐 */ );

List already has its own map method. Is the map in the example the List's map or the T? map?

You can see where this is going. A type T? can have any API.

T? is too generic! Furthermore, since T (not-nullable) is a subtype of T?, now also T has the full API of Option 🤯.

Magically all the type in your codebase became Option 🧙‍♂️

Not ideal I would say: I think you would agree with me that this is not a good choice 💁🏼‍♂️.

Solution: bring T? inside Option

There may be a solution to this.

Instead of brining the full API of Option inside T?, why not bringing T? inside Option?

What I mean is making easier to move from T? to Option when necessary, while keeping T? in all other cases.

The proposal would be to extend the API to add more methods to jump back and forth from T? to Option and vice versa.

Something like the example below 👇:

/// `fpdart` extension to chain methods on nullable values `T?`
extension FpdartOnNullable<T> on T? {
  Option<T> toOption() => Option.fromNullable(this);
 
  Either<L, T> toEither<L>(L Function(T?) onNull) =>
      Either.fromNullable(this, onNull);

This extension allows to convert T? to Option when you need to chain methods. Then you can just as easily come back to T? from Option using toNullable.


Currently this is still an open discussion. Feel free to jump in on Twitter or on the fpdart repository to share any feedback, idea, or suggestion 🎯.

👋・Interested in learning more, every week?

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.