DEV Community

Cover image for Safer code with container types (Either and Maybe)
Brian Neville-O'Neill
Brian Neville-O'Neill

Posted on • Originally published at blog.logrocket.com on

Safer code with container types (Either and Maybe)

Written by John Llenas✏️

The Maybe data type

There are only two hard things in Computer Science: null and undefined.

Well, Phil Karlton didn’t say exactly those words, but we can all agree that dealing with null, undefined, and the concept of emptiness in general is hard, right?

The absence of a value

Every time we annotate a variable with a Type, that variable can hold either a value of the annotated Type, null, or undefined (and sometimes even NaN!).

That means, to avoid errors like Cannot read property 'XYZ' of undefined, we must remember to consistently apply defensive programming techniques every time we use it.

Another tricky aspect of the above fact is that semantically, it’s very confusing what null and undefined should be used for. They can mean different things for different people. APIs also use them inconsistently.

LogRocket Free Trial Banner

What can go wrong?

Even if you apply defensive programming techniques, things can go wrong. There are all sort of ways in which you could end up with false negatives.

In this example, the number 0 will never be found because 0 is _falsy_, like undefined. The Array.find() result when the find operation doesn’t match anything.

const numToFind = 0;
const theNum = [0, 1, 2, 3].find(n => n === numToFind);
if (theNum) {
  console.log(`${theNum} was found`);
} else {
  console.log(`${theNum} was not found`);
}
Enter fullscreen mode Exit fullscreen mode

Sadly, defensive programming (aka undefined / null checks behind if statements) are also a common source of bugs.

Maybe to the rescue

Wouldn’t it be nice if we could consistently handle emptiness, with the help of the compiler and without false negatives?

There’s already something that does all that: it’s the Maybe data type (also known as Option).

Maybe encapsulates the idea of a value that might not be there.

A Maybe value can either be Just some value or Nothing.

type Maybe<T> = Just<T> | Nothing;
Enter fullscreen mode Exit fullscreen mode

We often talk about this kind of Types as _Container Types_, because their only purpose is to give semantic meaning to the value they hold and to allow you to perform specific operations on it in a safe way.

We are going to use the ts.data.maybe Library. Let’s get familiar with its API.

Our app has a User Type:

interface User {
  id: number;
  nickname: string;
  email: string;
  bio: string;
  dob: string;
}
Enter fullscreen mode Exit fullscreen mode

We also make a /users request to our REST API, which returns the following payload:

[
  (...)
  {
    "id": 1234,
    "nickname": "picklerick",
    "email": "pickle@rick.com",
    "bio": null
  }
  (...)
]
Enter fullscreen mode Exit fullscreen mode

At this point, if we annotate this payload with User[], lots of bad things can happen in our codebase because User is lying. We are expecting bio and dob to always be a string, but in this case, one is null and the other is undefined.

There’s a potential for runtime errors.

Type annotations with Maybe

Let’s fix this with Maybe.

import { Maybe } from "ts.data.maybe";

interface User {
  id: number;
  nickname: string;
  email: string;
  bio: Maybe<string>;
  dob: Maybe<Date>;
}
Enter fullscreen mode Exit fullscreen mode

Once you add Maybe to the equation, nothing is implicit anymore. There’s no way to get around that Type declaration — the compiler will always force you to treat bio and dob as Maybe.

Creating Maybe instances

Ok, but how do we use this?

Let’s create a User parser for our API result.

For this, we’ll create a UserJson Type, used only by the parser, that represents what we are getting from the server and another User Type that represents our domain model. We’ll use this Type throughout the application.

import { Maybe, just, nothing } from 'ts.data.maybe';

interface User {
  id: number;
  nickname: string;
  email: string;
  bio: Maybe<string>;
  dob: Maybe<Date>;
}

interface UserJson {
  id: number;
  nickname: string;
  email: string;
  bio?: string | null;
  dob?: string | null;
}
Enter fullscreen mode Exit fullscreen mode

As you can see, to create a Maybe instance, you have to use one of the available constructor functions:

just<T>(value: T): Maybe<T>

nothing<T>(): Maybe<T>

Notice how we’ve decided to define emptiness differently for bio and dob (date of birth).

We can represent bio with an empty string — there’s nothing wrong with that.

However, we cannot represent a Date with an empty string. That’s why the parser treats them differently, even though the data that comes from the server is a string for both.

Extracting values from Maybe

Now that we’ve managed to declare and create Maybe instances, let’s see how we can use them in our logic.

We plan to create an html representation of the list of users that we are getting from the server, and we are going to represent them with cards.

This is the function that we’ll use to generate the Html markup for the card:

const userCard = (user: User) => `<div class="card">
  <h2>${user.nickname}</h2>
  <p>${userBio(user.bio)}</p>
  <ul>
    <li>${user.email}</li>
    <li>${userDob(user.dob)}</li>
  </ul>
</div>`;
Enter fullscreen mode Exit fullscreen mode

Nothing special so far, but let’s see what those two functions that extract the values from Maybe look like:

userBio()

const userBio = (maybeBio: Maybe<string>) =>
  withDefault(maybeBio, '404 bio not found');
Enter fullscreen mode Exit fullscreen mode

Here, we have introduced a new Maybe API: the withDefault() function (also known as getOrElse() in other Maybe implementations).

withDefault<A>(value: Maybe<A>, defaultValue: A): A
Enter fullscreen mode Exit fullscreen mode

This function is used to extract the value from a Maybe instance.

If the Maybe instance is Nothing, then the default value will be returned — in this case, 404 bio not found. If the instance is a Just, it will unwrap and return the string value it contains.

i.e.

withDefault(just(5), 0) would return 5.

withDefault(nothing(), 'This is empty') would return This is empty.

userDob()

const userDob = (maybeDate: Maybe<Date>) =>
  caseOf(
    {
      Nothing: () => 'Date not provided',
      Just: date => date.toLocaleDateString()
    },
    maybeDate
  );
Enter fullscreen mode Exit fullscreen mode

Here, we are introducing another new MaybeAPI: the caseOf() function.

caseOf<A, B>(caseof: {Just: (v: A) => B; Nothing: () => B;}, value: Maybe<A>): B

In the userDob function, we don’t want to use withDefault because we need to perform some logic with the extracted value before returning it, and that’s precisely what the caseOf() function is useful for.

This function gives you an opportunity to make computations before returning the value.

Maybe-fying existing APIs

There’s one last thing that needs to be done to complete our application: we need to render our user cards.

The rendering logic involves dealing with DOM APIs, we need to get a reference to the div element where we want to insert our Html markup, and we’ll use the getElementById(elementId: string): null | HTMLElement function.

In our newly acquired obsession to avoid null and undefined, we’ve decided to create a _Maybefied_ version of this function to avoid dealing with null.

const maybeGetElementById = (id: string): Maybe<HTMLElement> => {
  const elem = document.getElementById(id);
  return elem === null ? nothing() : just(elem);
};
Enter fullscreen mode Exit fullscreen mode

Now the compiler won’t let us treat the result like it’s an HTMLElement, when in reality it could entirely be null if the div we are looking for is not in our page. Maybe has us covered.

Let’s use this function and render those user cards:

const maybeAppDiv: Maybe<HTMLElement> = maybeGetElementById('app');

caseOf(
  {
    Just: appDiv => {
      appDiv.innerHTML = '<h1>Users</h1>' + usersJson
        .map(userJson => userCard(userParser(userJson)))
        .join('<br>');
      return;
    },
    Nothing: () => {
      document.body.innerHTML = 'App div not found';
      return;
    }
  },
  maybeAppDiv
);
Enter fullscreen mode Exit fullscreen mode

You can play with the code of this example here.

We’ve seen just a few of the available Maybe APIs. You can do much more with this data type.

Go check the ts.data.maybe docs page to find out more.

The Either data type

Errors are an essential part of software development, ignore them and your program will fail to meet the user’s expectations.

Defining failure

As always, semantics are fundamental, and defining failure consistently is vital to making our programs easier to reason about.

So, what exactly defines failure? Let’s see some examples of the kind of errors you can find around:

  • An operation that throws a runtime exception.
  • An operation that returns an Error instance.
  • An operation that returns null.
  • An operation that returns an object with {error: true} in it.
  • When we reach the catch() clause in a Promise.

Most of the time, you’ll handle errors by branching your logic with if and try catch statements.

The resulting code can get messy quite rapidly because of the depth of nesting and intermediate variables that need to be defined to _transport_ the final result from one point of your code to another.

Either to the rescue

Wouldn’t it be nice if we could abstract all those if and try catch statements and reduce the number of intermediate variables that need to be defined?

There’s already something that does all that: it’s the Either data Type (also known as Result).

Either encapsulates the idea of a computation that may fail.

An Either value can either be Right of some value or Left of some error.

type Either<T> = Right<T> | Left;
Enter fullscreen mode Exit fullscreen mode

Looks familiar, right? It’s very similar to the Maybe type signature, although you’ll see how they differ in a moment.

We are going to use the ts.data.either Library. Let’s get familiar with its API.

The Either candidates

This time we are going to create a getUserById service that searches a user by id from a json file.

The service does the following:

  1. Validates that the Json file name is valid.
  2. Reads the Json file.
  3. Parses the Json into an object Graph.
  4. Finds the user in the array.
  5. Returns.

As you can see, every step has the potential for failure. That’s fine because we are going to use Either to keep errors under control.

Some utils for our example

Let’s create a few things our example relies on to work.

First, we are going to reuse the UserJson Type from the previous example:

export interface UserJson {
  id: number;
  nickname: string;
  email: string;
  bio?: string | null;
  dob?: string | null;
}
Enter fullscreen mode Exit fullscreen mode

We also need a (virtual) file system.

const fileSystem: { [key: string]: string } = {
  "something.json": `
        [
          {
            "id": 1,
            "nickname": "rick",
            "email": "rick@c137.com",
            "bio": "Rick Sanchez of Earth Dimension C-137",
            "dob": "3139-03-04T23:00:00.000Z"
          },
          {
            "id": 2,
            "nickname": "morty",
            "email": "morty@c137.com",
            "bio": null,
            "dob": "2005-04-08T22:00:00.000Z"
          }
        ]`
};
Enter fullscreen mode Exit fullscreen mode

We need a readFile(filename: string): string; function for our virtual file system that returns the file contents as a string if the file is found or throws an exception otherwise.

const readFile = (filename: string): string => {
  const fileContents = fileSystem[filename];
  if (fileContents === undefined) {
    throw new Error(`${filename} does not exists.`);
  }
  return fileContents;
};
Enter fullscreen mode Exit fullscreen mode

Finally, a (quick and dirty) pipeline function implementation, which will allow us to make our function calls flow similar to how fluent APIs do:

There are some libraries out there that do the same in a Typesafe way, but I didn’t want to include yet another dependency.

And there’s already a native JavaScript pipeline API implementation in the works!

export const pipeline = (initialValue: any, ...fns: Function[]) =>
  fns.reduce((acc, fn) => fn(acc), initialValue);
Enter fullscreen mode Exit fullscreen mode

So, instead of calling multiple functions like this:

add1( add1( add1( 5 ) ) ); // 8
Enter fullscreen mode Exit fullscreen mode

We can make it like this:

pipeline(
    5,
    n => add1(n), // we could go point-free and just use `add1`
    n => add1(n),
    n => add1(n)
); // 8
Enter fullscreen mode Exit fullscreen mode

Either composition

Our getUserById function is, in fact, a sequence of actions where the next depends on the outcome of the previous.

Each step does something that may fail and passes the result to the next one, and because of that, the best way to represent each of these steps is with functions returning Either.

1. Validating the Json filename

const validateJsonFilename = (filename: string): Either<string> =>
    filename.endsWith(".json")
      ? right(filename)
      : left(new Error(`${filename} is not a valid json file.`));
Enter fullscreen mode Exit fullscreen mode

Here we introduce the Left and Right constructor functions:

left(error: Error): Either;
Enter fullscreen mode Exit fullscreen mode
right(value: T): Either;
Enter fullscreen mode Exit fullscreen mode

The logic is quite straightforward (and naive): If the file doesn’t have .json extension, we return a Left, which means there was an error, otherwise a Right with the filename.

2. Reading the Json file

const readFileContent = (filename: string): Either<string> =>
    tryCatch(() => readFile(filename), err => err);
Enter fullscreen mode Exit fullscreen mode

As we saw previously, the readFile function throws an exception if the file is not found.

To control runtime errors Either has the tryCatch function:

tryCatch<A>(f: () => A, onError: (e: Error) => Error): Either<A>
Enter fullscreen mode Exit fullscreen mode

This function wraps logic that may throw and returns an Either instance.

tryCatch accepts two function parameters, one that is executed on success and another on failure.

On success, the result is returned wrapped in a Right, on failure the error generated from the source function is passed to the error handler and the result is returned wrapped in a Left.

3. Parsing Json

const parseJson = (json: string): Either<UserJson[]> =>
    tryCatch(
      () => JSON.parse(json),
      err => new Error(`There was an error parsing this Json.`)
    );
Enter fullscreen mode Exit fullscreen mode

Nothing new here, we use tryCatch because JSON.parse throws on failure.

4. Finding the user in the array

After all this error juggling it is time to search for the user, but let’s think about it, what happens if the provided id doesn’t match any user, should we return null, undefined or maybe Left? Oh! Remember Maybe? Let’s use it here too!

const findUserById = (users: UserJson[]): Either<Maybe<UserJson>> => {
  return pipeline(
    users.find(user => user.id === id),
    (user: UserJson) =>
      user === undefined ? nothing<UserJson>() : just(user),
    (user: Maybe<UserJson>) => right(user)
  );
};
Enter fullscreen mode Exit fullscreen mode

Wow, look at that return.

Type signature Either<Maybe<UserJson>>
Enter fullscreen mode Exit fullscreen mode

There’s so much stuff packed in so few characters… let’s recap:

Either -> contains a value or an error.

Maybe -> contains something or nothing.

UserJson -> contains a UserJson.

So, just by reading the signature findUserById(users: UserJson[]): Either; you know for sure that findUserById is part of an operation that might have failed (Either) and returns a UserJson that can be empty (Nothing). Not a small feat!

5. Returning a value

At this point, we have all the ingredients needed to declare our getUserById service. Let’s put it all together.

const getUserById = (filename: string, id: number): Either<Maybe<UserJson>> => {
  const validateJsonFilename = (filename: string): Either<string> =>
    filename.endsWith(".json")
      ? right(filename)
      : left(new Error(`${filename} is not a valid json file.`));

  const readFileContent = (filename: string): Either<string> =>
    tryCatch(() => readFile(filename), err => err);

  const parseJson = (json: string): Either<UserJson[]> =>
    tryCatch(
      () => JSON.parse(json),
      err => new Error(`There was an error parsing this Json.`)
    );

  const findUserById = (users: UserJson[]): Either<Maybe<UserJson>> => {
    return pipeline(
      users.find(user => user.id === id),
      (user: UserJson) =>
        user === undefined ? nothing<UserJson>() : just(user),
      (user: Maybe<UserJson>) => right(user)
    );
  };

  return pipeline(
    filename,
    (fname: string) => validateJsonFilename(fname),
    (fname: Either<string>) => andThen(readFileContent, fname),
    (json: Either<string>) => andThen(parseJson, json),
    (users: Either<UserJson[]>) => andThen(findUserById, users)
  );
};
Enter fullscreen mode Exit fullscreen mode

The only thing this function adds to what we’ve done in the four previous steps is compose them all in a pipeline, where each operation feeds its resulting Either to the next one thanks to this new Either Api we just introduced, the andThen function:

andThen<A, B>(f: (a: A) => Either<B>, value: Either<A>): Either<B>
Enter fullscreen mode Exit fullscreen mode

This function basically says:

— Give me an Either and I’ll return you another Either using this function that returns Either that you have to provide as well.

The way this function pipeline flows is as follows:

  1. Provide an initial value.
  2. Execute this function, if it fails, return the error in a Left, otherwise return the resulting value in a Right.
  3. If we got a Left from the previous function return that Left, otherwise execute this function, if it fails, return the error in a Left, otherwise, return the resulting value in a Right.
  4. If we got a Left from the previous function return that Left, otherwise execute this function, if it fails, return the error in a Left, otherwise, return the resulting value in a Right.
  5. If we got a Left from the previous function return that Left, otherwise execute this function, if it fails, return the error in a Left, otherwise, return the resulting value in a Right.

Did you notice that steps 3, 4 and 5 are the same? And that would be true for all intermediate operations that this pipeline might have. Once you get the idea, everything flows.

Using our getUserById service

Our service returns a UserJson buried two levels deep, one is an Either and the other is a Maybe. Let’s extract this valuable information from our container types.

The printUser function extracts the UserJson from the Maybe.

const printUser = (maybeUser: Maybe<UserJson>) =>
  maybeCaseOf(
    {
      Nothing: () => "User not found",
      Just: user => `${user.nickname}<${user.email}>`
    },
    maybeUser
  );
Enter fullscreen mode Exit fullscreen mode

Here, maybeCaseOf is an alias becasue both Either and Maybe have a function called caseOf that we use in the same source file.

You can create an alias importing the function like this: import { caseOf as maybeCaseOf } from "ts.data.maybe";

And finally! Let’s tie everything together:

console.log(
    caseOf(
      {
        Right: user => printUser(user),
        Left: err => `Error: ${err.message}`
      },
      getUserById("something.json", 1)
    )
); // rick<rick@c137.com>

console.log(
    caseOf(
      {
        Right: user => printUser(user),
        Left: err => `Error: ${err.message}`
      },
      getUserById("something.json", 444)
    )
); // User not found

console.log(
    caseOf(
      {
        Right: user => printUser(user),
        Left: err => `Error: ${err.message}`
      },
      getUserById("nothing.json", 2)
    )
); // Error: nothing.json does not exists.

console.log(
    caseOf(
      {
        Right: user => printUser(user),
        Left: err => `Error: ${err.message}`
      },
      getUserById("noExtension", 2)
    )
); // Error: noExtension is not a valid json file.
Enter fullscreen mode Exit fullscreen mode

You can play with the code of this example here.

We’ve seen just a few of the available Either APIs, and you can do much more with this data type.

Go check the ts.data.either docs page to find out more.

Conclusion

We’ve learned that container Types are wrappers for values that provide APIs so we can safely operate with them.

The Maybe container Type makes explicit the concept of emptiness, instead of relying on the inferior semantic and error-prone alternatives null and undefined we have this wrapper at our disposal that has a clearly defined API and semantic meaning.

The Either container Type encapsulates the concept of failure and offers an alternative to the verbosity of branching our code in if and try catch statements.

The clearly-defined composable APIs exposed by this type infect our programs, making them more functional, clean and more comfortable to read and reason about.


Plug: LogRocket, a DVR for web apps

 
LogRocket Dashboard Free Trial Banner
 
LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
 
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.
 
Try it for free.


The post Safer code with container types (Either and Maybe) appeared first on LogRocket Blog.

Top comments (1)

Collapse
 
martynasj profile image
Martynas Jankauskas

Enabling typescript's strict null checks could also be a great improvement to reduce runtime errors typescriptlang.org/docs/handbook/r...