Originally published at programming-elm.com.
In the previous post, we explored how boolean arguments obscure the intent of code. We replaced boolean arguments with custom type values to make code more explicit and maintainable.
In this post, you will discover that boolean return values cause a problem known as boolean blindness. Boolean blindness can create accidental bugs in if-else expressions that the compiler can't prevent. You will learn how to replace boolean return values with custom types to eliminate boolean blindness and leverage the compiler for safer code.
The Problem
In my talk, Solving the Boolean Identity Crisis, I share a tale from a lecture by Dan Licata, a professor at Wesleyan University.
Sometimes, when I'm walking down the street, someone will ask me "do you know what time it is?" If I feel like being a literalist, I'll say "yes." Then they roll their eyes and say "okay, [tell] me what time it is!" The downside of this is that they might get used to demanding the time, and start demanding it of people who don't even know it. It's better to ask "do you know what time is it, and if so, please tell me?". [T]hat's what "what time is it?" usually means. This way, you get the information you were after, when it's available.
If we translate this into code, it might look like this.
type alias Person =
{ time : String }
doYouKnowTheTime : Person -> Boolean
doYouKnowTheTime person =
person.time /= ""
tellMeTheTime : Person -> String
tellMeTheTime person =
person.time
currentTime : Person -> String
currentTime person =
if doYouKnowTheTime person then
tellMeTheTime person
else
"Does anybody really know what time it is?"
The doYouKnowTheTime
function accepts a Person
type and checks if the time
field isn't the empty string. Then, we branch on a call to doYouKnowTheTime
inside the currentTime
function. If it returns True
, then we call tellMeTheTime
to return the value of person.time
. Otherwise, we return a default time.
This code may look fine but it suffers from a couple of problems.
First, as Dan rightly points out, people could demand time of others that don't have it. Nothing stops us from writing this code.
currentTime person =
if doYouKnowTheTime person then
tellMeTheTime person
else
tellMeTheTime person -- returns empty string
We can still call tellMeTheTime
when person.time
is the empty string. This would likely cause a bug.
Second, the fact that we can cause the previous situation surfaces a data-modeling code smell. Strings notoriously cause trouble because any string is valid according to the type system. The compiler can't enforce that a given string is not empty. This is a weak substitute for a more meaningful data type.
We want to give the compiler better type information so it can constrain this code to only access the time when it's truly available. Let's explore how to make this code clearer and safer.
Fix the Boolean Blindness
The first problem stems from boolean blindness. When you reduce information to a boolean, you lose that information easily. The information that boolean carries is only known inside the if
check. As soon as you branch into the body of the if-else expression, you become blind to the original information that got you there. Because that boolean loses information, you must backtrack to recover it when you need it again.
Dan offers this solution to boolean blindness, "boolean tests let you look, options let you see."
Dan is referring to the option
type in ML. In Elm, we call it the Maybe
type. What Dan means is that booleans only tell you if something is present. The Maybe
type tells you if it's present by giving it to you when it's available. Let's rewrite our example with Maybe String
.
type alias Person =
{ time : Maybe String }
whatTimeIsIt : Person -> Maybe String
whatTimeIsIt person =
person.time
currentTime : Person -> String
currentTime person =
case whatTimeIsIt person of
Just time ->
time
Nothing ->
"Does anybody really know what time it is?"
We update the time
field to be Maybe String
. Then, we add a whatTimeIsIt
function that returns person.time
. Inside currentTime
we now call whatTimeIsIt
and pattern match on the result. If the person has the time, then we immediately have access to it inside Just
. No need to first check with an if-else expression. If the person doesn't have the time, i.e. Nothing
, then we return our default.
We can't accidentally access the time if it's not present because the compiler will enforce the Maybe
type constraint.
We still have a problem, though. The time inside Just
could be the empty string, which is an invalid time. Let's fix that next.
Use Time.Posix
We need a better type for encoding the time to avoid the empty string. Luckily, Elm has a package for working with time called elm/time. It offers a Posix
type to represent Unix time, or the amount of time that has passed since midnight UTC on January 1, 1970. We can use the Posix
type and then convert it to a formatted time when needed.
import Time exposing (Posix, toHour, toMinute, utc)
type alias Person =
{ time : Maybe Posix }
whatTimeIsIt : Person -> Maybe Posix
whatTimeIsIt person =
person.time
currentTime : Person -> String
currentTime person =
case whatTimeIsIt person of
Just time ->
String.fromInt (toHour utc time)
++ ":"
++ String.fromInt (toMinute utc time)
Nothing ->
"Does anybody really know what time it is?"
We import the Time
module and expose Posix
, toHour
, toMinute
, and utc
. We change the time
field to Maybe Posix
and update the type annotation for whatTimeIsIt
. Inside the Just
branch of currentTime
, we now know we have a valid time thanks to the Posix
type. We use the toHour
and toMinute
functions along with String.fromInt
and the utc
time zone to build a formatted string time.
This is great. Because of static types, the compiler will enforce our code to only access a valid time when it exists.
We could go one step further to improve this code. If a person doesn't have the time, then it's Nothing
. But, that doesn't explain why the person doesn't have time. We can replace Maybe
with our own custom type.
type CurrentTime
= CurrentTime Posix
| NoWatch
| InAHurry
type alias Person =
{ time : CurrentTime }
currentTime : Person -> String
currentTime person =
case whatTimeIsIt person of
CurrentTime time ->
String.fromInt (toHour utc time)
++ ":"
++ String.fromInt (toMinute utc time)
NoWatch ->
"I don't have the time."
InAHurry ->
"Sorry, I'm in a hurry."
We introduce a CurrentTime
custom type with three constructors, CurrentTime
, NoWatch
, and InAHurry
. The CurrentTime
constructor wraps Posix
. We then change the time
field to be CurrentTime
. In the currentTime
function, we handle all three constructors. The CurrentTime
branch stays the same as the previous Just
branch. The NoWatch
and InAHurry
branches each return a string that describes why the person doesn't have the time.
Now, we have made the code more precise about why a person doesn't have the time and have encoded better business domain rules into the code with custom types. Plus, we still have the compiler to ensure we can only access a valid time in the CurrentTime
branch.
What You Learned
In this post, you learned that boolean return values cause boolean blindness. You saw that boolean blindness can lead to human error by letting code access data in incorrect places. You discovered that built-in custom types such as Maybe
or your own custom type let you test and access the presence of data. Additionally, the compiler ensures you access data only when it's truly available. Try refactoring some of your own code to replace a boolean return value with a more meaningful custom type to make your code more maintainable.
To learn more about how to build Elm applications effectively, grab a copy of my book Programming Elm from The Pragmatic Programmers.
Top comments (0)