What I Like About Haskell: Data Structures As Control Flow
When you first encounter Haskell-inspired structures like Maybe
, Either
, or Reader
, they might seem needlessly complicated. I myself was guilty of thinking that these abstractions only served to obfuscate control flow that would be much more apparent and readable using if
/else
, try
/catch
or even ternary operators. Now, I might be inclined to say that those control flow statements from the C family of languages are familiar but not inherently more readable.[1]
After several months of making my way through the exercises in the Haskell book along with a healthy dose of playing around with my own stupid mini-programs, I received a moment of insight into the underlying design of Haskell that really illuminated for me why those data structures like Maybe
or Either
existed and why they felt so integrated with the overall usage and design of the Haskell language: Haskell manages control flow via data structures that are enforced by a robust type-checking system.
Let’s compare two possible implementations of a small code snippet, one using JavaScript and one using Haskell.
const data = ["algebra", "anarchist", "tofu"];
const elem = data.find(elem => elem === "tofu");
if (elem === undefined) {
console.error("Not found.");
process.exit();
}
console.log(elem.toUpperCase() + " found!")
Now admittedly, this example is a little contrived, but honestly, the overall pattern is something that we do all the time in web programming. We perform an operation that may contain some data we’re interested in then we extract and act on that data. Instead of Array.prototype.find
on a hard-coded array, an analogous operation could be performed by making a network request to a REST API, extracting data from a JSON response, and then acting on that data. In broad strokes, it’s pretty similar.
The important part of this code for our purposes is the error checking. Our element might not be in our data. In that case, Array.prototype.find
returns undefined
to tell us that it couldn’t find what we were looking for. If we don’t want runtime errors, we need to check our result before acting on that result with String.prototype.toUpperCase
. Afterall undefined
doesn’t have a toUpperCase
method.
Compare that with how we might do the same thing in Haskell:
import Data.List (find)
import Data.Char (toUpper)
import Data.Maybe (fromMaybe)
list :: [String]
list = ["algebra", "anarchist", "tofu"]
toUpperCase :: String -> String
toUpperCase s = fmap toUpper s
element = find (== "tofu") list
allCaps = fmap toUpperCase element
main = putStrLn ((fromMaybe "Not" allCaps) ++ " found")
If you’re unfamiliar to Haskell, you might think I’ve thrown out error checking, but I assure you that the program will not raise an exception even if "tofu"
isn’t found anywhere in our list. Data.List.find
has the following type signature:
find :: Foldable t => (a -> Bool) -> t a -> Maybe a
That’s a more generic type signature than we’re using, which enables folks to use find
with data structures other than lists. However, let’s rewrite that type signature specifically for lists for teaching purposes:
find :: (a -> Bool) -> [a] -> Maybe a
We supply find
with…
- A function that takes some item with the data type
a
and returns aBool
value telling us whether this is thea
we’re looking for. - A list of items with the type
a
.
In return, it gives us the item it found wrapped up in a Maybe
.
So what is Maybe
? Maybe
is a data structure that wraps up a value of some other type and provides us with the concept of either success or failure. If our operation succeed, we’ll get something wrapped up in a Just
constructor (e.g. Just "tofu"
), otherwise we’ll get a Nothing
value.
“Wait a minute,” you might be inclined to say, “Nothing
is just another name for undefined
. There’s no magic here,” and honestly, I could kind of agree with you before we get to the part where we’re actually acting on the data.
One of the really intriguing parts of Haskell is that it has a rich set of tools for managing data contained within these contexts like Maybe
. Using fmap
, we can use normal functions to interact with the data inside our context. In the code above, we know that element
is going to contain Just "tofu"
. fmap
applies the toUpperCase
function to the value inside that Just
constructor ("tofu"
) and then wraps the value back up (Just "TOFU"
).
Now, the truly interesting stuff happens when you’re dealing with a Nothing
value, indicating that our computation failed. When you fmap
over Nothing
, you get Nothing
right back with no runtime error or exceptions. The program’s control flow continues down the same linear path, acting appropriately based on the context that our Maybe
is giving.
After we’ve completed all the computations and transformations that our program needs, to actually output results of our computation, we use a helper function from Data.Maybe
called fromMaybe
to “unwrap” our value:
fromMaybe "Not" allCaps
If our allCaps
variable contains Just
something, we’ll throw out the Just
wrapper and use the value itself. If allCaps
contains Nothing
, we use a default value that we supply to the fromMaybe
function — in this case "Not"
.[2]
In this small contrived example, you still might not understand why Haskellers complain that other languages don’t have these concepts, but imagine a much more complicated program where you have multiple actions to take against data. Imagine you have multiple checks that might fail and need to return Nothing
. Haskell has tools to help us manage even very complicated control flow, and arguably more importantly, its type-checking system helps us manage all of this data-driven control flow.
Added Bonus: The fmap
Abstraction
Speaking of that toUpperCase
function defined in our sample code, let’s take a closer look at it.[3]
toUpperCase :: String -> String
toUpperCase s = fmap toUpper s
The type signature on the first line tells us that it takes a string as an argument, and returns a string as its result. If you're familiar with JavaScript, you might feel comfortable assuming that this function does something quite similar to String.prototype.toUpperCase
, and you'd be right. How it does it, however, is somewhat interesting.
Remember how we used fmap
to apply a function to a value inside a Maybe context? Well, much like in a lot of other languages, in Haskell, strings are lists of characters, and lists are just another context in Haskell. Where Maybe
tells us that a value may or may not exist, a list tells us that we possibly have multiple values to process. fmap
provides us with a generic interface for applying a function to the value(s) within a context without us needing to change our implementation each time to accommodate the different data structures involved.
fmap
is a great example of how Haskell uses interfaces to abstract around implementation details. Functional programming folks tend to call this sort of thing “declarative programming” because it very much feels like you’re telling the computer what you want to do rather than how to do it.
Prelude λ: fmap (+1) (Just 1)
Just 2
Prelude λ: fmap (+1) [1, 2, 3]
[2,3,4]
In the preceding example, it doesn't matter to me whether my function to add one to a numerical value is applied to one number or multiple numbers. It also gracefully handles conditions that might cause other languages to throw an exception:
Prelude λ: fmap (+1) Nothing
Nothing
Prelude λ: fmap (+1) []
[]
The practical benefit of this is that you really come to rely on fmap
. If a data structure exists, you're reasonably sure that you can use fmap
to apply a normal function to the value(s) “inside” without needing to worry. After you become comfortable with using these abstractions, it feels almost frictionless, and then when you go back to other languages you feel like you're swimming against the current.
Take JavaScript’s promises for example. I used to love them. I still think they're far better continuation-passing callbacks, but how much cooler would they be if you could fearlessly map over them without having to worry about having a .catch
tacked on to the end to handle issues?[4]
Wrapping Up: Where Do We Go From Here?
In this article, I really focused on Maybe
to make a concise point, but honestly Maybe
is just the start. Other Haskell data structures help us manage different contexts and control flows:
Either
: Possible failures with alternative values.Either
helps us manage code branching.Reader
/ReaderT
: A shared readable environment in which calculations need to take place. A config object, for example, might be place in aReader
context, allowing us to have multiple operations use that shared environment without having to “thread” arguments through a series of functions.State
: A shared readable and writeable environment in which calculations take place. As you might imagine, in a language where all values are immutable constants, this gives us the ability to “overwrite” temporary values. (Fans of Redux on the JavaScript side probably understand the value of this pretty well.)
If this article has piqued your curiosity about Haskell and you'd like to dive a little deeper. I wholeheartedly recommend both The Haskell Book as well as Learn You A Haskell For Great Good, and if I know you outside the Internet, I'm also happy to help with anything you get stuck on.
Tearing my own argument apart a little: As the Python community has taught us all, code is read far more often than it is written, and part of what makes code readable is familiarity. However, that argument can be used as a reinforcement of the status quo even when new paradigms might make our lives as developers easier in the face of ever-increasing complexity. ↩︎
fromMaybe
is a convenient function found in a library that ships with the delivered Haskell libraries (called the “Prelude”). You could also use a more generic catamorphism here like a fold, but I didn’t want to get too far in the weeds here. ↩︎For the more advanced FP and Haskell enthusiasts, yes, I know I could do an eta reduce here to make this
toUpperCase = fmap toUpper
. Since currying isn't a focus of this article, I didn't want to confuse my main point. ↩︎If this sort of thing sounds intriguing to you as a JavaScript developer, checking out Folktale’s Future data structure. ↩︎