Despite our efforts to test and validate our code, we still end up with bugs in production that are caused by invalid states in our system. Better testing isn't our only tool for preventing this from happening. Another way to make our code more robust is simply to prevent these states from existing in the first place.
The "Make Impossible States Impossible" principle is a simple yet powerful concept for designing more robust and resilient code. This principle advocates error prevention by not allowing code to enter an invalid or unconscious state. One way of applying this principle is to use binding types that encode our business rules. A type is a free test. We want to increase the compiler's ability to help us!
Applying this principle makes our APIs clearer, leaving no room for confusion. It allows us to work with valid types at all times, so there are no exception cases to deal with. The developers who use our code can use it without having to understand the implementation. All this saves us time, fewer tests to write and, above all, makes our code more robust.
In this article, we'll use three examples to explore how we can rethink our code to follow the "Make Impossible States Impossible" principle and gain in robustness.
Explicit interface: leave no room for confusion
When I use an API, I don't want to have to understand how it works to use it correctly. I shouldn't be able to create invalid states, no matter how much I know about the code.
Let's take the example of a UI component whose purpose is to display a block of text. We want to allow the user to choose how text overflow is handled. He can choose between :
- Display all text
- Display a certain number of lines followed by ellipsis
- Display text height with expand button
One version of the component interface I came across in my work looked like :
type Props = {
overflow?: "ellipsis" | "expandable",
numberOfLines? : number,
maxHeight?: number,
}
The problem with this kind of interface is that you try to mix several possibilities together and, to achieve this, you make everything optional. In reality, parameters are not really optional. The user therefore needs to be familiar with the implementation to know that if he wants an ellipsis overflow, he must pass the number of lines.
Since all the parameters are optional, we have some borderline cases to deal with, and therefore to test.
The Solution
Here's another way of defining our component's interface:
type Props = {
overflow: Ellipsis | Expandable | FullText
}
type Ellipsis = {
type : 'ellipsis',
numberOfLines: number
}
type Expandable = {
type : 'expandable',
maxHeight: number
}
type FullText = {
type : 'fullText'
}
By taking all our possibilities (Ellipsis or Expandable or FullText), we give the developer a choice. He doesn't need to know that he has to pass a certain number of lines in ellipsis mode. Our interface forces him to pass the right parameters according to the chosen mode.
There's no confusion or possible errors. We don't have to deal with the case where the user doesn't pass one of the properties. So we don't have to worry about how to deal with this case, and what's more, we have far fewer tests to run.
Interdependent states
Let's assume a system that wants to control the opening and closing of climate control devices. Let's start with a simplified version of two devices. One for heating and one for cooling.
The constraint: we can't heat at the same time as we cool.
Here's a possible representation of our model:
type Control = { heating: boolean, cooling: boolean}
An invalid state allowed by the type is
const control = { heating: true, cooling: true }
With this kind of design, you need to have a piece of code somewhere that checks that the condition is met. It's easy to make a mistake, forget that the rule exists and an inconsistency arises. We rely on developers being aware of the rule and thinking about it.
The Solution
Our situation comprises two interdependent states. We could think of uniting them, since one cannot exist at the same time as the other.
Here is a possible representation:
type Control = "heating" | "cooling" | "standBy"
The advantage is that it's now impossible to heat and cool at the same time. No need for testing or checking in the code. It's encoded in the type!
I also suggest using the term standBy for the case where we are neither heating up nor cooling down. I prefer not to use undefined or null, which poorly document our state.
Impossible Action
In the same vein, suppose you want to represent a heating appliance as a furnace. The furnace takes two to five minutes to start up.
The constraint: you can't turn off a furnace while it's starting up.
Here is a naive implementation of the furnace model:
class NaiveFurnace {
private isOn: boolean;
private isStarting: boolean;
public constructor(){
this.isOn = false;
this.isStarting = false;
}
public turnOn(){
this.isOn = true;
this.isStarting = true;
}
public turnOff(){
if (this.isStarting){
// throw an exception? return ?
}
this.isOn = false;
}
}
With this implementation, we have several impossible states and actions that are still allowed by the type :
- A furnace that is starting up can be shut down.
- The startup state cannot be validly represented, since the isOn property contradicts isStarting.
The problem is that the user of the class needs to know the constraint in order to use it correctly. He must also know that an exception will be thrown in such a situation and handle it. All because invalid states are allowed to exist. Finally, we need to make sure we have good test coverage.
The Solution
Here's one solution I'd like to suggest. I'm sure there are several, but the important thing is to keep in mind the "Make Impossible States Impossible" principle.
First, I list the possible states of the furnace. The enumeration will not be exposed to the outside world. In this way, I don't allow the client of my interface to switch from one state to another at will.
enum FurnaceStage {
Heating = "heating",
Starting = "starting",
StandBy = "standBy"
}
Next, I create types that represent the states of the furnace and the possible actions according to each:
export type StartingFurnace = { state: FurnaceStage.Starting }
export type HeatingFurnace = {
state: FurnaceStage.Heating,
turnOff: () => StandByFurnace
}
export type StandByFurnace = {
state: FurnaceStage.StandBy,
turnOn: () => StartingFurnace
}
export type Furnace = StartingFurnace | HeatingFurnace | StandByFurnace
I can finally create type constructors for each one (a bit like classes) to help me:
export const StandByFurncace = () : StandByFurnace => ({
state: FurnaceStage.StandBy,
turnOn : () => StartingFurnace()
});
const HeatingFurnace = () : HeatingFurnace => ({
state: FurnaceStage.Heating,
turnOff: () => StandByFurncace()
});
const StartingFurnace = (): StartingFurnace => ({
state: FurnaceStage.Starting,
});
Thanks to the new types I've introduced, I limit the possible actions depending on the state of my furnace, and the user has no choice but to follow the valid transitions according to the current state. He can't forget to check that the furnace can be closed, as pattern matching obliges him to do so.
It is also the user of my module who is responsible for choosing how to handle borderline cases. No exceptions are thrown in my module and all my functions are deterministic and pure. No testing is required; everything is included directly in my type.
Conclusion
The aim of this exercise was to get us thinking about our modeling to make it more relevant to our domain. As with everything in programming, nothing is absolute and everything should be a compromise. I don't think we should encode everything in our type. The code must remain simple and understandable. On the other hand, becoming aware of this principle has enabled me to critically assess the code I write and read.
"I encourage you to try asking yourself these questions too: does my type allow invalid states to be created? Is it worth my while to prevent these states from existing? Can anyone use my code without having to understand the implementation?"
Often, we can find more elegant and easier-to-reason solutions by thinking outside the box.
To find out more : :
Les articles en vedette
Structuring Your Work to Maximize Efficiency in Software Development
How to Justify a Refactor?
Starting with React: Immutability
Soyez les premiers au courant des derniers articles publiés
Abonnez-vous à l’infolettre pour ne jamais rater une nouvelle publication de notre blogue et toutes nos nouvelles.