Option
Motivation
The Option
type signifies the presence or absense of a value. In some
circumstances, a function won't be able to give a resulting value for every
input. These are called partial functions (as opposed to total functions)
since they are only defined for certain inputs. Let's have a look at an
example of a naively implemented partial function:
int naiveMax(List<int> xs) {
if (xs.isEmpty) {
throw UnimplementedError('What do we do?');
} else {
return xs.reduce((maxSoFar, element) => max(maxSoFar, element));
}
}
The intent is to find the maximum value in the provided List<int>
. But what
happens if the list is empty? In this example the function will throw
.
Exceptions are not an option when building purely functional programs.
They require that the context of caller be known at all times in order to
reason about what the ultimate value of the function will be.
Do we return 0? Do we return -99999? Either of those result in an ambiguous result that the caller of the function will need interpret. This violates one of the core tenets of functional programming: Local Reasoning. The caller shouldn't need to interpret the value. The type of the value should convey that on it's own!
So let's improve on the initial implementation of our function to use the
Option
type:
Option<int> betterMax(List<int> xs) {
if (xs.isEmpty) {
return const None();
} else {
return Some(xs.reduce((maxSoFar, element) => max(maxSoFar, element)));
}
}
By changing the type that's returned, we've indicated to the user (and the compiler) that this function may not be able to return a value. As a result, the developer must account for either case.
Let's make this function even more readable. Here's a more elegant way to
define our max
function even more concisely using the Option
API:
Option<int> betterYetMax(List<int> xs) =>
Option.when(() => xs.isNotEmpty, () => xs.reduce(max));
Combinators
map
You may ask yourself why we would ever want to use Option
when Dart has
nullable types. And while nullable types are a great addition to the language,
they aren't as powerful and expressive as Option
. On top of this, there are
still gaps in the nullable type system that make Option<int>
preferable to
int?
.
Let's take the following example and see how Option
can make our code more
readable and composable.
int? foo(String s) => throw UnimplementedError();
double bar(int s) => throw UnimplementedError();
// How can we pipe these 2 functions together to acheive this:
// final result = bar(foo('string'));
We want to feed the output of the first function into the second function. But using Dart's nullable types, this becomes verbose and more difficult to decipher:
// How can we pipe these 2 functions together to acheive this:
final resA = foo('string');
final result = resA != null ? bar(resA!) : null;
Compare that version with the null checking to this version using Option
:
Option<int> fooOpt(String s) => throw UnimplementedError();
double barOpt(int s) => throw UnimplementedError();
final resultOpt = fooOpt('string').map((i) => barOpt(i));
Now consider how this very small scenario would look like when you try to
compose the results from 3 functions together. Then 10 functions. The Option
API shines in these cases because functions compose. On top of this simple
excersice, Option
has a number of additional combinators like map
, filter
and others.
flatMap
Another common scenario arises when you want to feed one functions Option<A>
into another functions parameter:
Option<String> validate(String s) => Option.when(() => s.isNotEmpty, () => s);
Option<String> firstName(String s) {
final parts = s.split(' ');
if (parts.length == 2) {
return Some(parts.first);
} else {
return const None();
}
}
final nameA = validate('John Doe').flatMap(firstName); // Some('John')
final nameB = validate('Madonna').flatMap(firstName); // None
This example shows how you can chain functions that use Option
together to
create readable code. Achieving this level of expressiveness using nullable types
alone isn't possible.
mapN
Our final scenario, but certainly not last you'll encounter in the wild arises
when you have a few Option
values and want to combine them into something else.
Our starting point looks like this:
const firstN = Some('Tommy');
const middleN = Some('Lee');
const lastN = Some('Jones');
// Combine the 3 name parts into full name
final fullName1 = firstN.flatMap(
(first) => middleN.flatMap(
(middle) => lastN.map(
(last) => '$first $middle $last',
),
),
);
While this strategy works, you can certainly make the argument that it's not
particularly readable. Because this is such a common scenario, ribs includes a
mapN
combinator that takes care of all the messy flatMap
and map
-ing
for you:
// Combine the 3 name parts into full name using mapN
final fullName2 = (firstN, middleN, lastN).mapN((f, m, l) => '$f $m $l');
By creating a tuple of your Option
values, you can then use mapN
as a
shortcut to achieve what we're after. Note that the number of Option
values you're combining (arity) doesn't matter. Ribs will handle tuples up to
size 22!
mapN
isn't just used for combining Option
values. You'll find it used
for other data types as well including Either
, IO
and many more!
This is hardly an exhautive list of the Option
combinators so be sure to
explore the full API.