Skip to content

Latest commit

History

History
174 lines (127 loc) 路 5.08 KB

boolean-blindness.md

File metadata and controls

174 lines (127 loc) 路 5.08 KB

Boolean Blindness

It is tempting to use bool to represent a choice between different things. Unfortunately

  • true or false have no semantic meaning whatsoever, in any particular context, unless the context explicitly assigns meaning to them.

  • bool has only two values, and if our choices increase we can't add more values to it.

  • To add a third option, I need a second boolean value, and the amount of possible states increases in powers of 2.

Fortunately we have some tools to do this, each fitting different use cases better:

  1. Giving Names to Values
  2. Providing Evidence

Giving Names to Values

We can make use of both Variants and Polymorphic Variants to give meaning to our programs by more explicitly naming our values.

So whenever we see this:

let can_request = true;
let expect_deleted_files = false;

if (can_request && expect_deleted_files) {
  /** do something */
};

It can be written more semantically like this:

let request_status = `Can_request;
let expectation = `Files_were_deleted;

switch (request_status, expectation) {
| (`Can_request, `Files_were_deleted) => /** do something */
};

The advantages being that

  • I no longer rely on the name of a variable to understand what the value means.

  • If the request_status needs more than 2 options, it's trivial to add another one and the type-system will help me refactor along to make sure I miss nothing.

  • Because I have exactly as many options as I need, the surface area for illegal states is minimized, and the possible states scale linearly.

In particular I prefer polymorphic variants because they do not need to be declared beforehand and the type-system will help me unify them as I use them.

You can read more about them here: Polymorphic Variants, Real World OCaml.

Providing Evidence

The idea behing providing evidence is that if we can only create a value that is valid, the need for checks on it is gone because having a value at all is evidence enough that the value is valid. We can achieve this with Smart Constructors.

Instead of checking if our user is an admin, if we construct an admin from a user and from then on we can rely on it being an admin user. That is, having an admin user is evidence enough that we have a user that is an admin.

Sounds a little redundant, so let's see some code. Before we would have something like:

let user : User.t = { name: "Joe Camel", role: "admin" };
user |> User.is_admin == true;

We could instead write:

let user : User.t = { name: "Joe Camel" };
let admin : option(Admin.t) = Admin.from_user(user);

Let's dissect that second example. Clearly before we had a function that took a user and returned a boolean:

module User : {
  type t;
  let is_admin : t => bool;
};

But this means that we are relying on that boolean value all the time to perform checks on whether the user is or is not an admin. We could go back and give these values a name but we would still have to check every time we want to see if a User is an admin or not.

The alternative snippet provides a different function that seems small but has quite a big implication.

module Admin : {
  type t;
  let from_user : User.t => option(t);
};

If we give Admin.from_user a User.t value, maybe it returns us an Admin.t value. That is, not all Users are Admins, but if we get an Admin back, then we do not need to check anymore if that Admin is in fact an Admin.

Now let's expand the first approach to include two function calls, one that's meant to be for admin users, and another one that's meant to be for regular users.

let user : User.t = { name: "Joe Camel", role: "admin" };

if (user |> User.is_admin) {
  do_admin_stuff(user);
} else {
  do_user_stuff(user);
}

Where the type of both those functions is User.t => unit, we probably will have to check again inside of do_admin_stuff if the user is really an admin. So the problem here is that the following snippet is perfectly valid:

if (user |> User.is_admin) {
  do_user_stuff(user);
} else {
  do_admin_stuff(user);
}

I guess by now you also agree that this seems unnecessarily dangerous. Let's refactor both those functions so they are safer to work with:

let do_user_stuff : User.t => unit;
let do_admin_stuff: Admin.t => unit;

Voila. Now we need to get evidence that our user is an admin and we're good to go!

let user : User.t = { name: "Joe Camel" };
let admin : option(Admin.t) = Admin.from_user(user);

switch(admin) {
| Some(admin) => do_admin_stuff(admin)
| None => do_user_stuff(user)
};

Much more safe to work with, since calling do_admin_stuff(user) or do_user_stuff(admin) are both type errors.