DEV Community

Attila Večerek
Attila Večerek

Posted on • Updated on

The Option monad

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>;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 }>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }>
    );
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

When lifting an Either to an Option, "Left" is mapped to "None" and "Right" is mapped to "Some"

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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 given Option'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 to bindTo 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 an Option.

In the next post, we’ll take a look at how to write asynchronous code that composes well using the Task monad.

Extra resources

Top comments (0)