Typing: Option
Introduction
The Option type is a valuable programming construct designed to represent an optional value. It is usually used in functional programming languages and libraries to handle situations where a value may or may not be present. This type is called by different names based on the programming language; for example, Option in Rust, OCaml, F#, or Maybe in Haskell.
The Option type can encapsulate that a value can exist (Some) or be absent (None). This provides a way to explicitly handle the absence of a value without resorting to null references or checks.
In languages like Dart, where null safety is enforced, the Option type can be viewed as an extra layer of abstraction that promotes immutability, safety, and expressiveness when working with optional values.
The Option type usually comes with methods and operations that allow for manipulating the underlying value, such as mapping, filtering, and extracting the value if it exists. These methods permit the safe and concise handling of optional values without explicit null checks.
Using the Option type, developers can write more robust, easier-to-understand code less susceptible to null-related errors. It encourages a more functional programming style by providing a consistent and type-safe way to handle optional values throughout the codebase. Additionally, utilizing the Option type can create more readable code that is less error-prone and more maintainable in the long run.
Implementation
sealed class Option<T> {
bool isSome() {
switch (this) {
case (Some _):
return true;
case (None _):
return false;
}
}
bool isNone() {
switch (this) {
case (Some _):
return false;
case (None _):
return true;
}
}
bool isEqual(Option<T> other) {
switch ((this, other)) {
case (Some some, Some other):
return some.value == other.value;
case (None _, None _):
return true;
case _:
return false;
}
}
T getOrElse(T defVal) {
switch (this) {
case (Some some):
return some.value;
case (None _):
return defVal;
}
}
Option<T1> map<T1>(T1 Function(T) f) {
switch (this) {
case (Some some):
return Some(f(some.value));
case (None _):
return None();
}
}
Option<T1> flatmap<T1>(Option<T1> Function(T) f) {
switch (this) {
case (Some some):
return f(some.value);
case (None _):
return None();
}
}
Option<T> tap(void Function(T) f) {
switch (this) {
case (Some some):
f(some.value);
return Some(some.value);
case (None _):
return None();
}
}
String toString() {
switch (this) {
case (Some some):
return "Some(${some.value})";
case (None _):
return "None";
}
}
}
class Some<T> extends Option<T> {
final T value;
Some(this.value);
}
class None<T> extends Option<T> {
final value = Null;
}Explanation of the Dart code:
- The code defines a sealed class
Option<T>which represents an optional value that can either beSome(containing a value) orNone(empty). - The
Option<T>class has several methods:isSome()checks if the option isSomeand returnstrueif it is,falseotherwise.isNone()checks if the option isNoneand returnstrueif it is,falseotherwise.isEqual(Option<T> other)compares two options for equality. It returnstrueif both options areSomeand their values are equal, or if both options areNone. Otherwise, it returnsfalse.getOrElse(T defVal)returns the option’s value if it isSome, or the provided default valuedefValif it isNone.map<T1>(T1 Function(T) f)applies the functionfto the value of the option if it isSome, and returns a newOption<T1>with the result. If the option isNone, it returns a newNone.flatmap<T1>(Option<T1> Function(T) f)applies the functionfto the value of the option if it isSome, and returns the result. If the option isNone, it returns a newNone.tap(void Function(T) f)applies the functionfto the option’s value if it isSome, and returns the same option. If the option isNone, it returns a newNone. Thetapmethod is used to perform side effects, such as logging or updating external state, without modifying the value of the option.toString()returns a string representation of the option. If it isSome, it returns “Some(value)”, wherevalueis the option’s value. If it isNone, it returns “None”.
- The code also defines two subclasses of
Option<T>:Some<T>represents aSomeoption with a non-null value of typeT. It has a constructor that takes a value.None<T>represents aNoneoption with no value. It has avaluefield set toNull.
This code provides a way to work with optional values in Dart using the Option class. It emphasizes immutability and safely handling values by encapsulating them within the Option type. This ensures the value is either present (Some) or absent (None), eliminating the need for null checks and reducing the risk of null pointer exceptions.
The Option class provides methods for safely accessing and manipulating the value, such as getOrElse, map, flatmap, and tap. The tap method is specifically designed to perform side effects, allowing you to execute code that has an effect outside of the Option instance. This can be useful for logging, updating external state, or triggering other actions without modifying the option’s value.
Using the tap method, you can perform side effects in a controlled and predictable manner while maintaining the immutability and safety of the Option instance. This promotes a functional programming style where side effects are isolated and explicit, making the code easier to reason about and test.
void main() {
var someNumber = Some(1);
Option<int> noneNumber = None();
var someString = Some("hello");
Option<String> noneString = None();
print(someNumber); // Output: Some(1)
print(noneNumber); // Output: None
print(someString); // Output: Some(hello)
print(noneString); // Output: None
print(someNumber.isSome()); // Output: true
print(someNumber.isNone()); // Output: false
print(someNumber.isEqual(Some(1))); // Output: true
print(someNumber.isEqual(None())); // Output: false
print(someNumber.getOrElse(2)); // Output: 1
print(noneNumber.getOrElse(2)); // Output: 2
print(someNumber.map((x) => x + 1)); // Output: Some(2)
print(someString.map((x) => x + " world")); // Output: Some(hello world)
print(noneNumber.map((x) => x + 1)); // Output: None
print(someNumber.map((x) => x + 1).map((x) => x * 3)); // Output: Some(6)
print(someNumber); // Output: Some(1)
print(someNumber.flatmap((x) => Some(x + 1))); // Output: Some(2)
print(someString
.flatmap((x) => Some(x + " world"))); // Output: Some(hello world)
print(noneNumber.flatmap((x) => Some(x + 1))); // Output: None
print(someNumber
.flatmap((x) => Some(x + 1))
.flatmap((x) => Some(x * 3))); // Output: Some(6)
print(someNumber.tap((p0) {
print(p0);
})); // Output: Some(1), but print 1 first
}Let’s break down the main function step-by-step:
var someNumber = Some(1);: Here we create aSomeinstancesomeNumberwith the integer value1.Option<int> noneNumber = None();: We create aNoneinstancenoneNumberrepresenting no integer value.var someString = Some("hello");: We create aSomeinstancesomeStringwith the string value “hello”.Option<String> noneString = None();: We create aNoneinstancenoneStringrepresenting no string value.print(someNumber);: This line will printSome(1)becausesomeNumberis an instance ofSomecontaining value1.print(noneNumber);: This line will printNoneasnoneNumberis an instance ofNone.print(someString);: This will printSome(hello)assomeStringis aSomeinstance containing the string “hello”.print(noneString);: This will printNonebecausenoneStringis an instance ofNone.print(someNumber.isSome());: This will printtrueassomeNumberis an instance ofSome.print(someNumber.isNone());: This will printfalseassomeNumberis not aNoneinstance.print(someNumber.isEqual(Some(1)));: This will printtruebecausesomeNumberis equal toSome(1).print(someNumber.isEqual(None()));: This will printfalseassomeNumberis not equal toNone.print(someNumber.getOrElse(2));: This will print1becausesomeNumberis aSomeinstance and its value is1.print(noneNumber.getOrElse(2));: This will print2, which is the default value, asnoneNumberis aNoneinstance.print(someNumber.map((x) => x + 1));: This will printSome(2)because we are applying a function that increments the value insidesomeNumber.print(someString.map((x) => x + " world"));: This will printSome(hello world)because we are applying a function that appends " world" to the value insomeString.print(noneNumber.map((x) => x + 1));: This will printNonebecausenoneNumberis aNoneinstance and themapfunction won’t change it.print(someNumber.map((x) => x + 1).map((x) => x * 3));: This will printSome(6). The value insomeNumberis first incremented and then tripled.print(someNumber);: This will printSome(1)becausesomeNumberstill holds the value1.print(someNumber.flatmap((x) => Some(x + 1)));: This will printSome(2)because we apply a function that increments the value insidesomeNumberand wraps it inSome.print(someString.flatmap((x) => Some(x + " world")));: This will printSome(hello world)because we are applying a function that appends " world" to the value insomeStringand wraps it inSome.print(noneNumber.flatmap((x) => Some(x + 1)));: This will printNonebecausenoneNumberis aNoneinstance and theflatmapfunction won’t change it.print(someNumber.flatmap((x) => Some(x + 1)).flatmap((x) => Some(x * 3)));: This will printSome(6). The value insomeNumberis first incremented, wrapped inSome, then tripled, and wrapped inSomeagain.print(someNumber.tap((p0) {print(p0);}));: This will print1and thenSome(1). Thetapfunction prints the value insomeNumberand then printssomeNumberitself.