Making impossible states impossible with TypeScript
Tags: typescript
Published: 2020-10-12 20:29:41

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.

```ts
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:

```ts
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?

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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.

```ts
// 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:

```ts
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:

{% codesandbox charming-wright-rxewg %}