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?
andOption<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 calledMaybe
(from Haskell), then based on feedback from the community it was renamed toOption
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 tocanBeNull != 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 acceptOption<int>
String takesNullable(int? nullInt) => "$nullInt";
...
takesNullable(nullInt);
/// takesNullable(optionInt); βοΈ
takesNullable(optionInt.toNullable());
- The type is not aware of checks calling
isSome
orisNone
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
(orMaybe
, orOptional
) is a functional programming type. Functional programming languages useOption
instead ofnull
.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?
toOption
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 π―.