- Published on
Bool considered harmful?
- Authors
- Name
- Damian Płaza
- @raimeyuu
This article is based on the conversation I had with a fellow craftsman, a friend of mine - Przemek Wolnik
Kudos to Przemek!
No bool was harmed during writing this article.
Can nothing be active?
Imagine we have the following problem in front of us: we are getting a collection of users that has the following shape:
type User = {
readonly name: string
readonly isActive: boolean
}
We need to check that current collection contains only active users. Seems easy, we need to write a simple function - let's name it areAllUsersActive
.
const isActive = (user: User) => user.isActive
const areAllUsersActive =
(users: User[]) => users.every(isActive)
So far, so good.
We are utilizing this function in another level, e.g. to send some Christmas gifts.
function handleSendingChristmasGifts(users: User[]) {
if(areAllUsersActive(users)) {
const giftsDelivery = startGiftsDeliveryProcess() // 👈 very expensive operation!
const deliveries = giftsDelivery.to(users) // all users will get happiness and joy
trackDeliveries(deliveries) // we track what is the state of delivering joy and happiness
}
// ... some other important stuff
}
Turns out that to deliver happiness and joy, we need to start a special process. It is quite expensive (resource-wise: money, time, CPU, whatever you think of as expensive) so it's better to be cautious.
We started getting complains from delivery department that they have a lot of opened deliveries, but none of them is completed.
Strange. Gifts should be delivered and delivery tracking service should report them as completed.
After hours of detective work, talking to various people in the organization, outside of the organization, we decided to make a hypothesis: there is a bug.
From the logical standpoint, everything seems correct. As delivery department complained there are many opened deliveries, maybe it is something with the delivery process service that is broken?
Worth checking.
Hours pass, days move along and it seems like the behemoth, gift delivery process service, is also fine.
Ok, logs checking time.
Hours pass and turns out that delivery process is always started, even if no users were selected to send gifts to.
Wait, WHAT?!
Ok, now it seems "the business logic", the single if
causes this disruption in the business process.
Let's put our hypothesis in the form of a specification:
describe("areAllUsersActive", () => {
const noUsers: User[] = []
it("returns false given no users", () => {
expect(areAllUsersActive(noUsers)).toBe(false)
})
})
Running the specification gives...
Turns out that built-in Array.prototype.every
method returns true
when used on an empty array. Impressive, isn't it?
We could verbalize this scenario with the following reasoning: "There are no inactive users so all users are active". But is this actually true (pun intended)?
What is the problem?
In "Essentially bounded, accidentally unlimited", I said something like this:
There is no single solution, but there is a single problem.
Initial thought might be: "the problem is with a function returning true to empty users collection given".
Let's create multiple solutions to have something to choose from.
We could think of the solution as: "the function should return false when empty users collection given".
That would be easy win.
const isActive = (user: User) => user.isActive
const areAllUsersActive =
(users: User[]) => users.length > 0 && users.every(isActive)
Our test turns green:
Is this the actual problem?
No users - no problem?
What else could we do?
Even if we placed additional guard against empty collection, still something isn't right.
Imagine other places in the code, where no users might be a special, edgy case.
Our lovely areAllUsersActive
function can answer: "yes, all active" or "no, some active".
But empty users collection does not fit into any of those answers.
Unfortunately, the return type of areAllUsersActive
is boolean
.
Let's imagine that alternative solution is to extend the answers set with additional answer: "no users given".
How could we model that?
We need to change the implementation of this function. Let's start with defining the output type, according to the answers we are expecting to get:
type AllUsersActive = 'NO_USERS_GIVEN' | 'ALL_ACTIVE' | 'SOME_NOT_ACTIVE'
Then writing a specification of the new behavior:
describe('areAllUsersActive', () => {
const noUsers: User[] = []
type AreAllUsersActive = ReturnType<typeof areAllUsersActiveDU>
it('returns "NO_USERS_GIVEN given no users', () => {
expect(areAllUsersActiveDU(noUsers)).toBe<AreAllUsersActive>('NO_USERS_GIVEN') // 👈 not yet defined!
})
})
After a small number of TDD cycles, we arrive at the following implementation:
type AllUsersActive = 'NO_USERS_GIVEN' | 'ALL_ACTIVE' | 'SOME_NOT_ACTIVE'
const areAllUsersActiveDU = (users: User[]): AllUsersActive => {
if (users.length === 0) return 'NO_USERS_GIVEN'
return users.every(isActive) ? 'ALL_ACTIVE' : 'SOME_NOT_ACTIVE'
}
And this makes the specification green:
Further on, we need to adjust the guy who utilizes this function:
function handleSendingChristmasGifts(users: User[]) {
const allActive = areAllUsersActiveDU(users)
if (allActive === 'ALL_ACTIVE') {
const giftsDelivery = startGiftsDeliveryProcess() // 👈 very expensive operation!
const deliveries = giftsDelivery.to(users) // all users will get happiness and joy
trackDeliveries(deliveries) // we track what is the state of delivering joy and happiness
}
// ... some other important stuff
}
You might wonder what does "DU" suffix mean, at the end of the function name. For those of you who does not recognize it, it stands for "Discriminated Union". Technically speaking, It might be 100% accurate, but I believe you got the point.
Faster feedback?
Any other options?
We could ask our friend, the compiler, to help guiding us to the pits of success.
Let's try to prevent calling areAllUsersActive
functions if there are no users available.
We need to define NonEmptyArray<TType>
type:
type NonEmptyArrayOf<TType> = [TType, ...TType[]]
Then we would need to change the signature of our function to accept NonEmptyArrayOf<User>
, instead of just an array.
const isActive = (user: User) => user.isActive
const areAllUsersActive = (users: NonEmptyArrayOf<User>) =>
users.every(isActive)
Immediately after doing so, the compiler friendly gives us the reminder that there are two places to apply fixes:
describe('areAllUsersActiveDU', () => {
const noUsers: User[] = []
it('returns false given no users', () => {
expect(areAllUsersActive(noUsers)).toBe(false) // ❌ "Argument of type 'User[]' is not assignable to parameter of type 'NonEmptyArrayOf<User>'"
})
})
aaaaand here:
function handleSendingChristmasGifts(users: User[]) {
if (areAllUsersActive(users)) { // ❌ "Source provides no match for required element at position 0 in target."
const giftsDelivery = startGiftsDeliveryProcess()
const deliveries = giftsDelivery.to(users)
trackDeliveries(deliveries)
}
// ... some other important stuff
}
We still need to deal with boolean
answer from our function, but we are gently "reminded" to think of such scenario.
I am leaving completing this solution to you, dear Reader.
bool
considered harmful
Why considering bool
harmful?
Maybe I should be more precise here.
bool
might be considered harmful when trying to model states/precise answers.
This was a trivial, toy example, but imagine getting anwsers in the form of bool
s from various operations and the need of interpreting:
- what does the
false
mean? - or what does the
true
mean?
We might get into a paranoia quite fast, don't we?
One might say I am exaggerating now and there is additional, accidental complexity introduced.
I would say we are easily getting lured by the "simple" solutions. Being attracted to such approach might be a consequence of The cost of modeling.
It reminds me the first sentence from the great book, written by Craig Larman, "Applying UML and Patterns" (paraphrasing):
Programming is fun, but building maintainable software is hard.
Creating maintainable, readable, evolvable software is damn difficult - it requires putting effort into thinking, analyzing and capturing our thoughts in the form the code.
We can superficially approach problems we see, but what are the consequences? Aren't we "trivializing" the complexity of the reality?
Yet again, instead of rushing to write production code, it's vital and worthy to spend time on doing analysis, thought process to understand the problem we are going to solve.
bool
hater?
Am I a "bool hater"? Would I like to destroy all the bools?
Of course not.
It's not all good or all bad, but it is so easy to underestimate the scenario we are working with.
I think we, programmers, architects, have "status thinking" installed into our mind.
Primitive types like bool
share similar trait: they are very eager to "summon" their primitive friends at disposal.
Soon, a single isActive
status might attract another isRecommended
status, which then can invite yet another isInvalid
status to the party.
Such three amigos can bring havoc rapidly. Primitive types replicate with the speed of light.
"Status thinking" considered harmful
So maybe it's not about bool
that can be considered harmful, but "status thinking"?
What's more, bool
statuses do not scale.
What do I mean by "scaling" in this context?
As you, dear Reader, saw in our toy example and the solution suggested in No users - no problem?, we couldn't fit another "answer" in the two-case type.
Did I say "two-case"?
That's right. bool
can be "represented" as a union of two cases:
type bool_ = 'true' | 'false'
Adding "another" case sounds bad, isn't it?
If there is another "answer" a function might give as a result of computation, bool
can't hold it. It is not the level of abstraction bool
is operating on.
The language that bool
"knows" forbids that.
I like to think of programming constructs as people (Am I crazy? Please check "Organization-Driven Design", Conversation-Driven Design and "Microoffices" vs "Officeolith"!). By using this reasoning model, in order a bool
status to "give" all possible answers, such guy needs to get help from his friend: another bool
status. I hope you see where it leads, right?
One could say we infected our minds with "status thinking" and what's even worse - we taught our friends from product/business to communicate with statuses too.
To heal such "mental wounds" done by "status thinking" damage, we need to start from ourselves. There is no other option.
How to prevent summoning daemons from "Primitive Type" universe?
Model states, not statuses
One of possible ways is to model states explicitly.
States are for the essential complexity, for representing the concepts available in the domain you are designing the software for.
Aforementioned "domain", does not always need to mean "business domain". A problem space varies depending on the plane we are marching on. In our toy example, our problem space was related to rules regarding active users.
One of the suggested solutions incorporated "states modeling" approached.
As I mentioned in The cost of modeling:
(...) statuses are reserved for infrastructure side - one might say they are "optimized" for it.
bool
statues have their place, don't get me wrong. Infrastructure components love primitive types, because they are representing a "generic domain".
In "their" language there is no InactiveUser
or RecommendedSubscription
concepts. They know technical language and we need to align to that when speaking with an Infrastructure component.
One might argue that modeling states might bring performance-issues - that's why we, engineers, need to apply the proper solution to the given problem, including the nature of the problem and context we are working with.
Modeling states = state machines
Modeling states soon might get us into a whole new world of finite state machines. I firmly believe that we humans, by the bias of our minds, treat continuous, analogous world with in a discrete fashion.
Hence thinking with discrete states, events (facts) and processes driving transitions between the set number of states, is very natural to us (blogpost on this topic will come soon!)
Somehow, working with the infrastructure tools placed ourselves in using low-level primitive types when communicating/thinking.
Such an approach is not optimized for how we describe the world we see, we influence and we live in.
Next time dear Reader, try to ask yourself:
- are there are some hidden states that got buried under
bool
statuses? - what states are possible?
- what is impossible?
- how would
bool
status(es) evolve/"scale"?
Happy thinking!