46 comments

[ 0.27 ms ] story [ 102 ms ] thread
tl;dr: use sum types to model mutually incompatible states, rather than multiple booleans.
I get the point, but...

>Wait—that last state doesn't make sense. With a real door, you can technically turn the key while it's open, but does that meaningfully change its state?

Yes, you can't close a door that is open but the lock is turned. So this state actually makes sense in the real world.

And by examining the model, we discovered a flaw in our understanding, that doors can lock open, which is maybe undesired or dangerous. So this model was successful.
> which is maybe undesired or dangerous

Or desired. Perhaps the user had the need to keep the door from shutting and didn't have a wedge at hand.

Which has its own unexpected bug in that the extended deadbolt can damage the wood frame and/or trim. Or perhaps the sensor you use to determine the door's state (open or closed) is positioned on the hinge side in such a way that it's triggered when the door is kept open with the deadlock extended. It's close enough to closed that the sensor gets triggered, and you--I can't help myself--open the door to the possibility that your system falsely represents the door as being both closed and locked.

This stuff can be hard, and sometimes, you just have to refactor later on instead of trying to nail down every possibility for the model, despite trying to figure out likely issues in the near-term. In which case, the goal is to keep in mind that data models will eventually change over time, and consider how that knowledge might change what you're writing now to make that process less painful.

> Which has its own unexpected bug in that the extended deadbolt can damage the wood frame and/or trim.

It doesn't. The data is correctly representing the state of the system. Your "bug" assumes a lot of things about the environment, any of which may or may not be true depending on the specifics where the system is running.

> Or perhaps the sensor you use to determine the door's state (open or closed) is positioned on the hinge side in such a way that it's triggered when the door is kept open with the deadlock extended.

Again, an implementation problem. The sensor was installed in a way that's incompatible with the underlying system. Can it be fixed by changing the data model? Unlikely. In fact, I don't think it can be fixed at all. If the sensor lies, there's nothing the underlying system or the data model can do about it, especially if it's a binary sensor like the one described.

If the deadbolt was installed in such a way that it never crossed into the door frame but rather only moved inside the actual door, would you consider it a bug in the lock, the deadbolt, or simply bad installation?

But if the model doesn't reflect reality, then reality breaks the software. That's how bugs happen.

Which means that the non buggy model that best reflect reality, and covers all possible scenarios that will happen in production is the two boolean one, not the enum.

All mental models fail to represent the real world completely. That’s kind of a fundamental feature of them.
Yes, because they are focused on the relevant parts for solving a specific problem.

So one could say that the article's fundamental flaw is not explaining what is the purpose of the model, therefore we cannot assess if it is capturing the relevant aspects to solve the problem.

I thought the same thing. And now the "locked" enum state doesn't just mean locked, it means closed AND locked. And I don't actually like it when an enum value implies multiple things.
In fact, if you try to close a door that is locked, you’ll put a large dent in the door jamb. So this combination is not only sensical but desirable to detect.

Perhaps a better lesson is to not be overzealous when paring down your logical model.

Exactly! Beat me to it.

This is a constant source of bugs in software: developer making a "reasonable" judgement that gets crushed by reality.

The core idea is true, though. Enumeration types, set operations and pattern matching are must-have features in any PL.

(comment deleted)
And you need that state in the game Grim Fandango (spoiler) to solve a puzzle: lock an open door before the maintenance demon leaves. Then when he leaves he’ll close the door. But it won’t be close. So you can get inside.
> Yes, you can't close a door that is open but the lock is turned.

That depends. If it's a deadbolt, you're correct. If it's the lock in a doorknob, you probably can. This is typically how I lock my front door when I leave, I lock it then close it. Cars doors can also generally be locked then closed.

I like booleans, sometimes when I'm starting those nested if statements, I'll just go ahead and take the cases, enumerate all of the conditions in a "truth table" and write code accordingly for each case. The best thing about this is that you see all of the cases and can spot the undesirable or invalid cases and plan for them. Just like the article says, you spot an odd case (door locked but open) and can get a "heads up". The only thing is that the article seems to be against booleans so watch out for just enumerating the cases you expect because as mentioned, those "door locked but open" cases could actually be possible but unexpected.
One could invent as many wrong mapping scenarios with enums too. The trap is not the boolean. The fact is that domain modeling is hard, and it becomes even harder if you want to avoid booleans.
Yes, and like the author discovered, domains change over time. Software on the other hand, tends to stick around.
Yeah this feels like a “one way doors” problem. Designing your data models in a way that would cause a later refactor to be painful or impossible is probably the bigger trap.
Booleans get you an exponentially growing number of states. The failure mode of booleans is for some of those states to be nonsensical. The failure mode of enums [0] is having to enumerate out all of the valid states when that number of valid states is growing exponentially.

The real problem here is the same tension of typed vs untyped programs, applied to data constraints more specific than types. In an untyped language/database/etc, the expectation of isOpen could be updated to include an additional valid state of String("locked") as a third state - ugly but semantically correct (and of course now you have possible logic errors at every test of isLocked based on whether conditionals were properly created based on more than 2 boolean states (and possible how the language shortcuts "naked boolean" conditions)).

In an environment with the ability to express constraints on the data, the state (isOpen=true, isLocked=true) could be prohibited - a straightforward solution that requires some deliberate data modeling work.

[0] correctly used, as in the author's door example when pertaining to a door that automatically unlocks when going open->closed (which is actually relatively rare! so if this had been an instance of modeling the real world, I question whether it would have been actually correct!). Meanwhile the author's PremiumFeature example is really just using an enum to create a booleans in a different form, and doesn't actually support their thesis.

Algebraic Data Types. /thread
I agree with you, and the two posters who suggested "enums". This seesm to be confirmation that these terms are interchangeable, and refer to a choice type. Perhaps a distinction is that ADTs can hold optionally hold an inner value, like in rust. Fair enough?

And perhaps to the title: Misusing booleans is a trap.

Right, one might say that ADTs are a generalizaiton of the Enum concept.
ADTs are the space generated recursively by sums and products. Enums are a single flat sum type.
That’s the case for algebraic data types in Go. Otherwise you need to remember the impossible states whenever you change the code. It’s worse if some more complex fields only make sense in a specific state. Enums can’t help there.

Languages in the ML family solved this decades ago. But in the last 30 years this didn’t go mainstream.

> But in the last 30 years this didn’t go mainstream.

It's started to in the last few years (last decade?). First OO languages started getting type-safe enums, which still suck but at least aren't as bad as C-style enums, then most recent languages got either actual sum types or something more or equivalent (type-safe unions, sealed classes / interfaces).

> Languages in the ML family solved this decades ago. But in the last 30 years this didn’t go mainstream.

Rust is mainstream.

(comment deleted)
I've been working with a pretty specific data model now for about 8 years. One of the booleans that has turned into a trap for us is called

    isFavorite = true|false
This was originally meant to indicate an item would be pinned to the end users dashboard. Turns out that over time the business unit wanted to allow users to pin items to more then just a dashboard. If we had known better we would have used a set like

    favoriteLocations = Set<Locations>
but because this data was modeled across user devices in firmware and not just a central db, we were kinda stuck with adding more booleans for more locations to pin an item.
I understand that it could have been easier from the start by using a set. But at the time, it wasn't a requirement. Why would you use a set for a single entry?

Furthermore, you didn't need to keep using booleans. You could run a script that reads the boolean and update the new field in the row, and transition your code to use that.

And then some time later someone adds a nullable boolean to a column in your database and that null (3rd) value ends up representing different things.
Even worse, MySQL typically uses TINYINT a 1-byte integer to represent a boolean value, so you could have NULL, FALSE(0), TRUE(1), and 254 other 'TRUE' values between -128..127 (not counting the 0..1 canonical values).
I've absolutely had a real life door in the locked-open state, that is done to prevent it from closing. As some doors always need a key to open from the outside, this is useful. And as they can be further locked to need a key even from inside, that's not making "closed-locked" redundant instead.

For the general case, I think this may have been the inspiration for swift having enums with associated values, which in the author's toy example would have been

  enum DoorState {
    case open
    case closed(Bool)
  }
(comment deleted)
I'd use a set (or map, or "tags") for the complex example. Basically, you're saying that booleans are hard to enumerate and combine. How you combine states is pretty arbitrary and dependent. For the company partner/AI/features case, just create a set/map called "features" and put a price on each of them. Create a method to check for common combinations, like being a basic customer.
YES! This is the post I have been looking for all my life to point to when all the "enums are a bad pattern" folks come out of the woodwork. I've never understood why people think that - I mean, why would so many languages have enums in the first place if they were "bad". Anyways, great post!
Do you have examples? I haven't heard of this phenomenon.
For me, the best way to solve this, is to make it really easy to change between one and the other, at least at the beginning when that's the only state and there is not much code yet.

Start with the easy, and change it when you need it.

If you use the easy boolean, but your code requires a different class for each layer with that boolean (I'm looking at you DDD) if you need to change to an enum...good luck touching everything.

If you start with the complex enum, thinking that the boolean will need to change, then you'll realize that you don't need an enum or a boolean, but a string, and all that extra complexity is now in the way.

Boolean or enum, doesn't really matter, but make it so that you can change it as easily as possible, if needed

To me it seems like the real world example is one of applying bandaids to the domain model rather than refactoring it. The boolean flag(s) were a good solution until they weren’t, after which they should have been replaced with something else.
I think the better takeaway is that sometimes you should have the courage to design "ugly" data models if your domain demands it.

Or put differently, don't let language or database affordances dictate your domain models.

There are many situations where either your own abstractions or the abstractions of the language sort of "project back" into your domain model and "suggest" certain features to implement or special cases to handle.

E.g. if you modeled some state as a bunch of boolean (or enum!) fields, then the language suggests that every combination of those field values should map to some meaningful real-world situation. But there is no real reason to assume this! Sometimes one flag only makes sense in the context of another flag, sometimes a specific combination of flags could theoretically occur, but is uninteresting as a business usecase.

The same can happen with self-designed abstractions and interfaces: If "everything" in your system is a CRUD entity, then there will be some temptation to think of some meaningful "update" or "delete" operation for every entity - even if no one actually asked for those features.

In those situations, it's OK to declare certain combination of values as "invalid" (e.g. if isOpen is true, the isLocked must be false) or as "equivalent" (e.g. if isOpen is true, then isLocked is ignored).

OOP dweeb here. Since "Door" is an object, shouldn't the behavior be encapsulated and accessible to the client only via messaging? Then, we have "open", "close", "lock", and "unlock" -- and however this is implemented is the object's business, and implemented according to current business rules. The business rules may change, but the interface won't.

If we want to know the Door object's state, that's another set of messages.

If the door is open and locked, we don't know what's supposed to happen until we reference the business rules, which is what a lot of people on here are alluding to. Maybe we have a deadbolt, but maybe it's fine to assume that the user is going to unlock the door if necessary before closing it, and that the closed door is now in an unlocked state until the "lock" message is sent.

However this is implemented, our tests will only be on the Door object's interface, and we'll be testing the business rules.

Don't follow any dogma and instead be aware of alternatives and consider them for each use-case:

  - boolean
  - nullable boolean
  - nullable datetime/timestamp (e.g. closed_at)
  - status 'enum'/string/code
  - combinations of the above
>As developers, we love our booleans. They map perfectly into how computers work at a low level

no, booleans do not map perfectly onto how computers work at a low level: what happens when you add two booleans together?

C handles booleans how computers work at a low level.

Now you might say, well, adding two booleans together is an xor for that bit, and an and for the carry bit... would that were true, but we don't have access to that part of the ALU, it is already configured to be a full adder.

the only parts of the lowlevel bitwise boolean/binary mix that C is missing (and should be added) is the overflow flag and the carry flag

> seemingly simple modeling decisions can have unexpected consequences as systems grow.

And is this not the cruz of the engineering Goldilocks problem? We need to solve the problem at hand, not the one we might have in a year.

There is a good argument to be made against over-engineering.