Skip to main content

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:

Our First 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:

An Improvement
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:

Using 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.

Contrived Nullable Functions
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:

Composing Nullable Values
// 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:

Composing Option Values
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:

Composing Option Values
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:

Combining Option Values - Naive
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:

Combining Option Values - Naive
// 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!

tip

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.