Making impossible states impossible with TypeScript

lieberkind

Tomas Lieberkind

Posted on October 12, 2020

Making impossible states impossible with TypeScript

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:

  1. A symbol is unique, meaning that no other value will be equal to it
  2. 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:

💖 💪 🙅 🚩
lieberkind
Tomas Lieberkind

Posted on October 12, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related