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.
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`);
}
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;
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;
}
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
}
(...)
]
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>;
}
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;
}
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>`;
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');
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
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
);
Here, we are introducing another new Maybe
API: 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);
};
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
);
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 aPromise.
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;
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:
- Validates that the Json file name is valid.
- Reads the Json file.
- Parses the Json into an object Graph.
- Finds the user in the array.
- 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;
}
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"
}
]`
};
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;
};
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);
So, instead of calling multiple functions like this:
add1( add1( add1( 5 ) ) ); // 8
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
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.`));
Here we introduce the Left
and Right
constructor functions:
left(error: Error): Either;
right(value: T): Either;
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);
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>
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.`)
);
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)
);
};
Wow, look at that return.
Type signature Either<Maybe<UserJson>>
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)
);
};
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>
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:
- Provide an initial value.
- Execute this function, if it fails, return the error in a
Left
, otherwise return the resulting value in aRight
. - If we got a
Left
from the previous function return thatLeft
, otherwise execute this function, if it fails, return the error in aLeft
, otherwise, return the resulting value in aRight
. - If we got a
Left
from the previous function return thatLeft
, otherwise execute this function, if it fails, return the error in aLeft
, otherwise, return the resulting value in aRight
. - If we got a
Left
from the previous function return thatLeft
, otherwise execute this function, if it fails, return the error in aLeft
, otherwise, return the resulting value in aRight
.
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
);
Here,
maybeCaseOf
is an alias becasue bothEither
andMaybe
have a function calledcaseOf
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.
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 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)
Enabling typescript's strict null checks could also be a great improvement to reduce runtime errors typescriptlang.org/docs/handbook/r...