Building a Maybe in JavaScript
Cannot read property "x" of undefined. I'm guessing if you're reading this you've seen that message before, and probably at some point wanted to throw something through your monitor. We have the venerable Sir Tony Hoare to thank for this - he invented the null reference in 1965 while creating ALGOL-C. In fairness, he later went on to say
"I call it my billion-dollar mistake."
Fortunately there are some functional programming techniques we can use to ease the pain in a clean, concise and reliable way. Let's imagine we want to extract the value of the property "c" from the following object, and append the string " is great".
const a = {
b: {
c: "fp"
}
};
The simple approach we would use might be:
const appendString = (obj) =>
obj.b.c + " is great";
appendString(a);
This works fine, but sadly we must live in the real world. As a result the data we get back will sometimes take these forms:
const a = {
b: {}
};
// or
const a = {};
If we apply our appendString function to these values of a, things are going to explode...
Cannot read property "c" of undefined.
The imperative approach to this problem might be to add some null checking to our function:
const appendString = (obj) => {
if (!obj || !obj.b || !obj.b.c) return null;
return obj.b.c + " is great";
}
This works, but its ugly and error-prone. We also have to define specific (correct) null checks for every type of object we want to parse, which just isn't much fun. Here's where the Maybe can come to the rescue.
Our basic maybe
Essentially the maybe object we're going to construct will encapsulate the concept that its value may be null, and take care of the complexity that ensues. Following the Elm lead, we'll call Maybe's two conceptual states Maybe.just and Maybe.nothing. For starters, let's simply define an isNothing method that returns a boolean telling us if the Maybe contains nothing:
const isNullOrUndef = (value) => value === null || typeof value === "undefined";
const maybe = (value) => ({
isNothing: () => isNullOrUndef(value)
});
And a simple factory function to create our maybes - as we'll want to add some more methods later we'll define this on an object:
const Maybe = {
just: maybe,
nothing: () => maybe(null)
};
So now we can do this:
const maybeNumberOne = Maybe.just("a value");
const maybeNumberTwo = Maybe.nothing();
maybeNumberOne.isNothing(); // false
maybeNumberTwo.isNothing(); // true
All well and good, but not very useful so far. Programming is all about transforming data, so we need to have a way of transforming our maybes - a map function. This map function will take a function argument representing the transformation we wish to make, and return a new maybe containing the result of the transformation. Importantly, if the maybe wraps nothing, then the function won't be applied, we'll just return a new maybe.nothing:
const maybe = (value) => ({
isNothing: () => isNullOrUndef(value),
map: (transformer) => !isNullOrUndef(value) ? Maybe.just(transformer(value)) : Maybe.nothing()
});
Now we can use this to work with our maybes:
const maybeOne = Maybe.just(5);
maybeOne.map(x => x + 1); // Maybe.just(6);
const maybeTwo = Maybe.nothing();
maybeTwo.map(x => x + 1) // Maybe.nothing();
A key point is that maybe#map returns a new maybe, so we can chain these operations together. Going back to our original problem we could now do:
const a = {
b: {
c: "fp"
}
};
const maybeA = Maybe.just(a)
.map(a => a.b)
.map(b => b.c)
.map(c => c + " is great!");
The great thing here is that if any of the steps in the chain return null, we still get a result of Maybe.nothing at the end, rather than a runtime error.
Point-free chaining
If you read my previous post on curried functions then you'll know that we can do better than this. We can make higher-order functions for extracting named properties from an object, and for appending a string:
const prop = (propName) => (obj) => obj[propName];
const append = (appendee) => (appendix) => appendee + appendix;
So now we can use this function in our map chain:
const a = {
b: {
c: "fp"
}
};
const maybeA = Maybe.just(a)
.map(prop("b"))
.map(prop("c"))
.map(append(" is great!"));
We're getting somewhere now - we have null-proofed our extraction process and refactored it to a point-free style. All we need to do now is make our logic re-usable - what we want is to be able to pass our maybe to a function and have all the steps applied so we can re-use the extractor on many different maybes:
const extractor = // what we're about to make
extractor(Maybe.just(a)); // Maybe.just("fp is great")
What we need is a function that takes our steps, and sequentially calls the #map method of our Maybe with each step. We'll call the function Maybe#chain, and we can implement it with a reducer:
const Maybe = {
just: maybe,
nothing: () => maybe(null),
chain: (...fns) => (input) => fns.reduce((output, curr) => output.map(curr), input)
};
We can now build a re-usable function that can be applied to maybes:
const appendToC = Maybe.chain(
prop("b"),
prop("c"),
append(" is great!")
);
and use it on a variety of inputs:
const goodInput = Maybe.just({
b: {
c: "fp"
}
});
const badInput = Maybe.just({});
appendToC(goodInput); // Maybe.just("fp is great!")
appendToC(badInput); // Maybe.nothing()
Although I wasn't accustomed to the concept of Maybe values before learning Elm, they are something I didn't want to leave behind when working in JavaScript. If we are going to use them in a truly universal way then we will need to add some more functionality, so I'll be writing a follow up post soon looking at working with arrays of Maybe values.
Subscribe to Developing Thoughts
Get the latest posts delivered right to your inbox