Encode possible states in the type system.

This post is the second part of the Two engineering principles to help me sleep better at night series. If you haven’t read the first part, I highly recommend it before you continue.

  1. Encode errors in the type system
  2. Encode possible states in the type system

If you remember in the previous post, we used sum types to model the output of a computation. By encoding errors in a sum, TypeScript always made sure we handled all possible errors in our program. No runtime exception!

type Sum = A | B

Sum types are useful in many other situations. We’re going to talk about one of them today. Removing impossible states.

Intro

State is a comprehensive term we use to describe the various forms a program can take. It evolves over time to reflect changes made by users or other systems. We design state to hold information related to our domain and often times compute new data from it.

So what happens if our state isn’t what we think it is? It’s a bug.

Let’s take a look at this function to read a file.

declare const readFile: (filename: string, done: (error: null | Error, data: null | string) => void);

readFile("bear.txt", (error, data) => {
  if (error) {
    console.error("Reading this file did not work")
  } else {
    console.log("We've got data")
  }
});

This API has been used quite extensively in popular JavaScript libraries and is a source of great anxiety for me.

At first, this function seems harmless, but take a closer look at the done callback type. Following the type, I will list four possible ways we can call this callback and would be allowed by the type checker.

declare const done: (error: null | Error, data: null | string) => void;

done(null, null); // Sure! No error but no data. Seems legit.
done(null, "hello world"); // Hell yeah! We've got some data without errors.
done(new Error("file does not exist)", null); // Yeah I mean...shit happens
done(new Error("no errors?"), "hello world"); // Huh?!

Have you noticed anything weird about the last call? As developers, we know this doesn’t make a lot of sense. How could our program both be in an error and successful state…it is impossible.

Nothing in the type system prevents you from calling this callback with the wrong values. Nothing prevents you from writing that code by mistake. Unless, we change how we design impossible states.

Impossible states are impossible

Let’s look at our readFile definition again.

declare const readFile: (filename: string, done: (error: null | Error, data: null | string) => void);

If we simplify our reasoning about reading a file, the possible states can either be an error or data. Never both. As it stands right now, our done callback allows receiving any combination of error and data. The error state has two variants null and Error. The data state has also two variants null and string.

We can say we have a state of error * data (2 x 2 = 4 variants).

That, is a product type.

Okay, let’s pedal back for a second.
To understand what I’m talking about, we’ll slightly modify our function definition to use a record parameter instead of two.

-done: (error: null | Error, data: null | string) => void);
+done: (opts: {error: null | Error, data: null | string}) => void);

We can clearly see how our state can now be represented like the following.

type State = {
  error: null | Error,
  data: null | string
}

Do you see why I said our state equals error * data? Because product types allow every combination of their members which is represented as a multiplication in math.

We can inline all variants like this

const a: State = {
  error: null,
  data: null,
};

const b: State = {
  error: null,
  data: "hello"
};

const c: State = {
  error: new Error("File does no exist"),
  data: null
};

const d: State = {
  error: new Error("no errors?"),
  data: "Hello world"
};

Notice how this is similar to every valid calls of our done callback that we’ve listed earlier.

If our state can either be error or data, product types aren’t the best way to design it. Instead, we can fetch in our magic hat for a better solution…I’ll give you sum types.

Sum types to the rescue

If you remember from the first part of this series Encode errors in the type system, we’ve said that sum types can help model possible discriminated values of a given domain. It can either be A or Bnever both.

Does that sound familiar to what we’re trying to do here? Yes!

type Sum = A | B

Let’s attempt to rewrite our readFile function to use a sum type instead of a product type.

-done: (error: null | Error, data: null | string) => void);
+done: (state: Error | string) => void);

And let’s list all possible calls to done which will pass type checking.

done(new Error("Bad file, bad!"));
done("hello world")

That’s it! Do you see how we encoded the possible states in the type system? TypeScript will never let us call done with a state that cannot happen. We modelled our domain into the type system.

Here are some benefits:

  • Reduce the areas that need to be tested.
  • Increase confidence in the types and reduce business logic.

Pushing as much as we can to the static type checker can remove burden from our shoulders and reduce the areas where bugs can slip in.

Another case study

Before we wrap this up, I’d like to talk about another example from a React codebase.
Notice how this code doesn’t prevent impossible states.

const Component = () => {
  const [isLoading, setIsLoading] = React.useState(false);
  const [isError, setIsError] = React.useState(false);

  if (isError) {
    return "Oops!"
  } else if (isLoading) {
    return "Loading..."
  } else {
    return null;
  }
}

Given our state hooks, we can say the state is a product type.

type ComponentState = {
  isLoading: boolean;
  isError: boolean;
}

Let me ask you this. Can this component be both in an error and a loading state? If the answer is no which is the case most of the time, then nothing will prevent you from setting both states to true which is impossible.

We can rewrite away the product type and use a sum instead.

type State = {type: "Initial"} | {type: "Loading"} | {type: "Error"};

const [state, setState] = React.useState<State>({type: "Initial"});

setState({type: "Loading"});
setState({type: "Error"});

TypeScript will now never allow us to create an impossible state; possible variants are encoded in the type system. Additionally, it will also force us to handle all possible cases which is not the case if we’re using two or multiple booleans to represent one piece of data.

The boolean type is often times a poor choice to model state because it doesn’t scale past its two possible values true and false, even if it’s a sum type ;).

Conclusion

Let’s recap what we’ve learned today.

Thinking about state differently and modelling it using a sum lets us remove impossible states from our application. By enforcing valid variants in the type system, we’ve completely removed an area of possible bugs and business logic that would have been otherwise unit-tested.

In my experience, there are a lot of software concerns you can model as a sum. Here are a few examples.

type Theme = Dark | Light;

type Route = Home | Settings | Login | Join

type Data<A> = Initial | Pending | Error | Success<A>

// etc...

One last thing, I wanted to share with you a talk I saw a few years ago from Richard Feldman. It really opened my eyes back then and I still think it’s relevant today.

Watch Making Impossible States Impossible.

Get those pesky states out of your mind, into the type system and sleep better at night ✌️.

Last updated