Just like the Either monad in the previous post, the Option
monad also specifies an Option data type and the functions operating on top of it. In this post, we'll also learn that once we know how to work with one monad, we pretty much know how to work with all of them.
The Option
data type represents a value that may not be present. That's also why in some functional languages this monad is called the Maybe
monad. Practically speaking, it allows us to get rid of the use of A | null
and A | undefined
kind of types. Consequently, it also removes the need for constant null checks in our code. This is how fp-ts
defines Option
[1]:
interface None {
readonly _tag: "None";
}
interface Some<A> {
readonly _tag: "Some";
readonly value: A;
}
export type Option<A> = None | Some<A>;
Option
is a tagged union consisting of the None
and Some
types. None
represents the missing value, whereas Some
wraps the value of type A
. In a sense, it is a specialized form of Either<undefined, A>
. This type is usually returned from lookup functions:
- accessing an index of an array,
- accessing a property of an object,
- looking up a specific record in a database.
Constructors
The Option
monad has two basic constructors, one for each tag:
import { option } from "fp-ts";
export const missing = option.none;
export const present = option.some(42);
It can also construct a new Option
from a nullable type (A | null | undefined
). Very useful in the inevitable case of working with some popular libraries that do return such values.
import { option } from "fp-ts";
export const missing = option.fromNullable(null); // None
export const alsoMissing = option.fromNullable(undefined); // None
export const present = option.fromNullable(42); // Some(42)
Mapping
As opposed to Either, the Option
monad implements a single map
function. The None
type does not wrap any value, hence there's nothing to map from. However, there is an exception to this when lifting Option to Either. The concept of lifting is discussed in more details in a later section of this post.
import { option, record } from "fp-ts";
import { flow } from "fp-ts/lib/function.js";
interface LookupCount {
(_: Record<string, number>): option.Option<{ count: number }>;
}
const lookupCount: LookupCount = flow(
record.lookup("total"),
option.map((count) => ({ count }))
);
export const res = lookupCount({ total: 42 });
// Value of res is { _tag: "Some", value: { count: 42 } }
export const res2 = lookupCount({});
// Value of res2 is { _tag: "None" }
// Type of both res and res2 is Option<{ count: number }>
In the above example, we define a function called lookupCount
that takes a Record, checks if it contains a property called total
and maps it to an object of type { count: number }
returning a Some
. If the property does not exist, it returns a None
.
Our previous examples did not contain manual typings and relied on type inference instead. Unfortunately, in this example, explicitly typing the lookupCount
function is necessary. It is likely that TypeScript cannot infer the type of the looked up property correctly [2] when record.lookup
is used in a flow
. Nevertheless, I've reported this issue in fp-ts.
Without using the Option
monad, the example code may look like this:
interface LookupCount {
(_: Record<string, number>): { count: number } | null;
}
const lookupCount: LookupCount = (r) => {
if (r["total"]) {
return { count: r["total"] };
} else {
return null;
}
};
export const res = lookupCount({ total: 42 });
// Value of res is { count: 42 };
export const res2 = lookupCount({});
// Value of res2 is null
// Type of both res and res2 is { count: 42 } | null
Even though the implementation looks fairly neat, the disadvantage of this approach is incurred at the call site. Working with the result down the line requires us to check whether the value is null
every single time until a fallback value is defined or an error is thrown. Using the Option
monad, we can simply keep on mapping and chaining functions completely removing the need for null checks.
Chaining
option.chain
works exactly the same way as either.chain
does.
import { option, record } from "fp-ts";
import { pipe } from "fp-ts/lib/function.js";
const makeCount = (x: string, y: string) => (r: Record<string, number>) =>
pipe(
record.lookup(x, r),
option.chain((a) =>
pipe(
record.lookup(y, r),
option.map((b) => ({ count: a + b }))
)
)
);
const countDogsAndCats = makeCount("dogs", "cats");
export const res = countDogsAndCats({ dogs: 21, cats: 21 });
// Value of res is { _tag: "Right", value: { count: 42 } }
export const res2 = countDogsAndCats({});
// Value of res2 is { _tag: "None" }
// Type of both res and res2 is Option<{ count: number }>
In the above example, the code performs two record lookups. Only if both lookups result in a hit, the values are added together and wrapped in an object of type { count: number }
. This implementation isn't perfect, though. Imagine we'd need to perform five lookups and additions in total. This approach would yield deeply nested code. We'll demonstrate how to improve this in the bind section.
Not using the Option
monad, we arrive at a simpler implementation of makeCount
. However, the approach suffers from the same disadvantages as the imperative code example under mapping.
const makeCount = (x: string, y: string) => (r: Record<string, number>) => {
if (r[x] && r[y]) {
// @ts-expect-error TS still thinks r[x] and r[y] may possibly be undefined
return r[x] + r[y];
}
return null;
};
const countDogsAndCats = makeCount("dogs", "cats");
export const res = countDogsAndCats({ dogs: 21, cats: 21 });
// Value of res is { count: 42 }
export const res2 = countDogsAndCats({});
// Value of res2 is null
// Type of both res and res2 is { count: number } | null
Other functions
Similarly to Either, the Option
monad also implements other functions. In fact, it implements the same functions described in the previous post: getOrElse
, getOrElseW
, fold
, chainFirst
, chainFirstW
, and some more (except for getValidation
, which is Either-specific). These functions work the exact same way in every monad that implements them. Hence, there is no point in going through them again.
In the rest of this section, we'll demonstrate a new set of functions that are also implemented by Either
and more or less all the monads explored in the rest of this series.
bind
bind
combines chain with an assignment to a readonly property of a typed object. It returns a corresponding instance of Option
.
import { option, record } from "fp-ts";
import { pipe } from "fp-ts/lib/function.js";
const makeLookupCount = (x: string) => (r: Record<string, number>) =>
pipe(
option.some({}),
option.bind("count", () => record.lookup(x, r))
);
const lookupCount = makeLookupCount("total");
export const res = lookupCount({ total: 42 });
// Value of res is { _tag: "Some", value: { count: 42 } }
export const res2 = lookupCount({});
// Value of res2 is { _tag: "None" }
// Type of both res and res2 is Option<{ readonly count: number }>
bindTo
bindTo
creates a new typed object and assigns the Some
value under a readonly key of our choice. It also returns a corresponding instance of Option
.
import { option, record } from "fp-ts";
import { pipe } from "fp-ts/lib/function.js";
export const makeLookupCount = (x: string) => (r: Record<string, number>) =>
pipe(record.lookup(x, r), option.bindTo("count"));
// makeLookupCount returns Option<{ readonly count: number }>
// after the full application of its parameters
With bind
and bindTo
, we can improve makeCount
's implementation from our earlier example for chaining:
import { option, record } from "fp-ts";
import { pipe } from "fp-ts/lib/function.js";
export const makeCount =
(x: string, y: string) => (r: Record<string, number>) =>
pipe(
record.lookup(x, r), // Option<number>
option.bindTo("a"), // Option<{ a: number }>
option.bind("b", () => record.lookup(y, r)), // Option<{ a: number; b: number }>
option.map(({ a, b }) => ({ count: a + b })) // Option<{ count: number }>
);
With this approach, we avoid deeply nested structures making our code more readable and maintainable.
foldW
foldW
is similar to fold
from the previous post. The difference is that the None
and Some
handler functions no longer have to return the same type. This results in a widened return type:
import { option } from "fp-ts";
import { pipe } from "fp-ts/lib/function.js";
export const res = pipe(
option.some(42),
option.foldW(
() => ({ _tag: "Fallback" as const, value: 0 }),
(value) => ({ _tag: "Derived" as const, value })
)
);
// Value of res is { _tag: "Derived", value: 42 }
// Type of res is { _tag: "Derived", value: number } | { _tag: "Fallback", value: number }
tryCatch
tryCatch
creates a functional wrapper around functions performing a synchronous computation that can fail by throwing an exception. We can use this to our advantage when working with popular libraries or native functions written in a non-functional style.
import { option } from "fp-ts";
import * as fs from "fs";
export const res = option.tryCatch(() => fs.readFileSync("/path/to/your/file"));
// Type of res is Option<Buffer>
In the above example, if readFileSync
throws an error - e.g. the file does not exist - the value of res will be an instance of option.None
. This is a good solution if the missing file can be handled down the line. Should we want to keep the original error around, e.g. for reporting reasons, we may want to use either.tryCatch
instead.
Lifting
Now that we know two monads, we can delve into the concept of lifting. Up until now, all our examples were composing functions within the same monad. Oftentimes, we have to compose functions across different monads. In fp-ts
, we can achieve this by "lifting one monad to another" by applying a function that takes a value of one type of a monad and returns a value of another:
Assume we had a value of type Either<Error, Record<string, number>>
, and we wanted to lookup the value of a key in case it was a Right
. One option would be to extract that value and pipe it into record.lookup
.
import { either, record } from "fp-ts";
import { pipe } from "fp-ts/lib/function.js";
export const res = pipe(
either.right({ total: 42 } as Record<string, number>),
either.getOrElse(() => ({} as Record<string, number>)),
record.lookup("total")
);
// Value of res is { _tag: "Some", value: 42 }
// Type of res is Option<number>
However, in that case, we would have to handle the Left
type by specifying some kind of a fallback logic. We might not want to do that just yet. Therefor, the other option is to lift the Either
to an Option
and chain record.lookup
:
import { either, option, record } from "fp-ts";
import { pipe } from "fp-ts/lib/function.js";
export const res = pipe(
either.right({ total: 42 } as Record<string, number>),
option.fromEither,
option.chain(record.lookup("total"))
);
// Value of res is { _tag: "Some", value: 42 }
// Type of res is Option<number>
This way, we don't need to handle the Left
value ourselves since it is mapped to a corresponding None
value by option.fromEither
.
Even though the name of the concept may suggest so, lifting is not necessarily unidirectional. We can lift an Option
to an Either
almost as easily:
import { either, option } from "fp-ts";
import { pipe } from "fp-ts/lib/function.js";
export const res = pipe(
option.some(42), // Option<number>
either.fromOption(() => "Missing value") // Either<string, number>
);
// Value of res is { _tag: "Right", value: 42 }
The only difference is that we have to pass in a function to specify how to lift the None
value to a Left<E>
. Remember, the None value does not hold any actual value, there's none. This is not the case with Left
, which is a generic type.
Wrap-up
-
Option
represents optionality or nullability.-
None
stands for a missing value. -
Some
stands for a present value.
-
- We use:
-
bindTo
: to create a typed object and assign a givenOption
's value to a specific key of that object. This returns an Option wrapping that typed object. -
bind
: to chain a computation and assign the resulting Option's value to a specific key of a typed object. It often follows a call tobindTo
and solves the issue of deeply nested chaining. -
foldW
: to extract a single value from an Option while applying transformations to both the none and some values while widening the Some type. -
tryCatch
: to create a functional wrapper around synchronous computations that may fail by throwing an exception. It is usually used over functions coming from third-party libraries or native functions written in a non-functional style.
-
-
Lifting
helps us compose functions across different types of monads by taking one monad, such as Either, and transforming it into another, for example anOption
.
In the next post, we’ll take a look at how to write asynchronous code that composes well using the Task
monad.
Top comments (0)