Making impossible states impossible with TypeScript
Tomas Lieberkind
Posted on October 12, 2020
A few months back I was tasked with creating a simple feature at work. I was to add a small section on the company profile where our users could enter a link to their company's privacy policy link. Here's the list of requirements:
If they have never entered the privacy policy before, the box containing the privacy policy link should state "No privacy policy entered"
If they had entered the privacy policy link, the box should simply show a clickable link
When the company click "Edit" they the link changes into a text field where they'd be able to edit the link to the company's privacy policy
Easy, right? Let's see how we can model this in TypeScript.
type PrivacyPolicy = {
link: string;
isEditing: boolean;
}
The link
field would obviously hold the link to the privacy policy, and the isEditing
boolean would allow us to switch between view and edit mode.
Then comes another requirement:
- When the users are editing, they should be able to click "Cancel" to revert the changes made to the link before they started editing.
Okay, no problem. Let's expand our type:
type PrivacyPolicy = {
link: string;
isEditing: boolean;
oldValue: string;
}
So when the user starts "Edit" we store the current value of link
in oldValue
and set the isEditing
string to true
. When the user clicks "Cancel", we set the value of link
to what's in the oldValue
field and set the isEditing
link back to false
. Job done.
Now, this approach works, but it requires the consumer of our PrivacyPolicy
type to be meticulous about setting and resetting the values of the fields in the type, and we can easily get into situations where it's hard to make sense of the state of our type. To illustrate, what does the following instance mean?
const privacyPolicy: PrivacyPolicy = {
link: "http://google.com",
isEditing: false,
oldValue: "http://yahoo.com"
};
It probably means that the user has already made the changes required, and that the old value just isn't cleared when the changes are saved. This leads to the question "does the oldValue
field even make sense when the user is not editing?".
Let's look at another example that satisfies our type:
const privacyPolicy: PrivacyPolicy = {
link: "",
isEditing: true,
oldValue: ""
};
This probably means that the user is currently editing the link for the first time (based on the lack of value for oldValue
).
While this works, the next developer who comes in and has to make changes to this feature have to derive all of this themselves. Let's try and improve our types a little so the next developer won't have to scratch their head so much.
First of all, let's address the oldValue
field. It only makes sense in the case where we're editing. So we can split our type into two; into representing the "editing" case and one representing the viewing case:
type Editing = {
kind: 'editing';
link: string;
oldValue: string;
};
type Viewing = {
kind: 'viewing';
link: string;
};
type PrivacyPolicy = Editing | Viewing;
Instead of flipping a pesky boolean, we can now switch between two types which clearly identify the state that the user is in. Furthermore, we have eliminated the oldValue
field from the case where the user is just viewing the privacy policy link. Great!
Now, if we have a PrivacyPolicy
instance looking like this where the link
field is the empty string we know it's because no privacy policy link has been set up:
const privacyPolicy: PrivacyPolicy = {
kind: 'viewing',
link: "",
};
To be even more clear about this, we could consider extracting a third state of our PrivacyPolicy
type which represents that no link has been entered:
type NoneSet {
kind: 'none-set';
}
type Editing = {
kind: 'editing';
link: string;
oldValue: string;
};
type Viewing = {
kind: 'viewing';
link: string;
};
type PrivacyPolicy = NoneSet | Viewing | Editing;
Okay, nice. So now we have clearly stated the intent of our code. There are three states in which the privacy policy can be; it either hasn't been set, it's been set and the user is viewing it or the user is currently editing it.
We've come a long way in making our types represent the real-world scenario as closely as possible, but there's still one thing that can be improved. There's no type level enforcement that the values held in the link
field of our Viewing
type actually adhere to a real-world link. It could be any string:
const privacyPolicy: PrivacyPolicy = {
kind: 'viewing',
link: 'haha, bad luck amigo!',
};
To create this guarantee, we will have to use a technique called opaque types. A file containing an opaque type will expose an interface which is can only be instantiated in the context of the file. As TypeScript uses structural typing this is a bit tricky, but can be achieved by the use of symbols. Let's create a file called link.ts
with the following contents. I'll explain below how it all works.
// file: link.ts
const linkTag = Symbol('LINK-SYMBOL');
export type Link = {
[linkTag]: 'LINK';
link: string;
};
export const fromString = (link: string): Link | null => {
if (isValidUrl(link)) {
return {
[linkTag]: 'LINK',
link: link,
};
} else {
return null;
}
};
const isValidUrl = (candidate: string): boolean => {
// the implementation of this function is unimportant
// but it will return a boolean based on whether
// `candidate` is a valid URL
};
Okay, so what's going on here? First, we declare a symbol called linkTag
. Symbols are EcmaScript primitives like strings, numbers and booleans. There are two properties of symbols that are interesting to our use case:
- A symbol is unique, meaning that no other value will be equal to it
- Just like strings, they can be used as keys for object properties
In combination this can be used to break out of TypeScript's structural nature; no other places in our system can create values that's assignable to Link
! If we try, the compiler will throw an error.
Now we can update our PrivacyPolicy
types:
type NoneSet {
kind: 'none-set';
}
type Editing = {
kind: 'editing';
link: string;
oldValue: Link | null;
};
type Viewing = {
kind: 'viewing';
link: Link;
};
type PrivacyPolicy = NoneSet | Viewing | Editing;
So when we're viewing a link we know that it must be a valid URL. Why? Because the only way we can create an instance of the Link
type is through our fromString
function, which validates the string typed in by our beloved user.
In the case where we're editing, the oldValue
field either holds a valid Link
or null
. The null
case must mean that there wasn't a previous link set.
Okay, enough! Let's see an example! Alright, here you go:
Posted on October 12, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.