Making impossible states impossible ft. Zod and Typescript
Tags: webdev, typescript, zod
Published: 2022-12-19 12:51:48


There are some patterns in the typed functional world that are quite handy. The most prominent pattern is that of using Union and Product Types to model data to avoid bugs.

The title of this blog post is in fact lifted from a great talk given by [Richard Feldman](https://twitter.com/rtfeldman):

{% embed https://www.youtube.com/watch?v=IcgmSRJHu_8 %}

Now the above talk showcases a language called Elm.

Can you believe that the app built in `Elm` hasn't had a single runtime exception? It still blows my mind!

But thanks to TS we have similar abilities that we can leverage!

Let's see some examples with Union Types -

Assume this is an online car seller and as a dev, we modelled the Car type as:

```tsx
type Car = {
  isElectric: boolean;
  isCombustion: boolean;
  frunkSpace?: number;
  bootSpace: number;
};
```

Now let's write a function to calculate the total storage space in the Car:

```tsx
function getTotalCarStorage(car: Car) {
  if (car.isCombustion) {
    return car.bootSpace;
  }
  // for electric car there could be front trunk space!
  const { frunkSpace = 0 } = car;
  return car.bootSpace + frunkSpace;
}
```

But there is a potential bug in this above code. Can anyone spot that?

Well what if by mistake someone passed the object as:

```tsx
const porscheTaychan: Car = {
  isCombustion: true,
  isElectric: true,
  frunkSpace: 10,
  bootSpace: 200,
};
```

The result would be **200** when the answer we are expecting here is **210**!

Here the problem is booleans!

Because we allowed this possibility - isCombustion and isElectric can be true!

Now, sure we could add some tests around it and make sure that if both are true we throw an error or handle it some other way.

But by changing the type to use a Union type we can remove the possibility altogether:

```tsx
type Car =
  | {
      kind: "electric";
      frunkSpace: number;
      bootSpace: number;
    }
  | { kind: "combustion"; bootSpace: number };

function getTotalCarStorage(car: Car) {
  switch (car.kind) {
    case "combustion":
      return car.bootSpace;
    case "electric":
      return car.bootSpace + car.frunkSpace;
  }
}
```

If you try and access the `frunkSpace` property inside “combustion” case TS won’t let it compile:

![TS Error 1](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y5j715oapyl7p0atq5ys.png)

But wait this can get even better, let's say now a new type of Car has launched - a hybrid one! And we need to make sure that the case is handled in the above function!

How can we make sure that the function handles all the scenarios 🤔 ?

We add a `never` case:

```tsx
type Car =
  | {
      kind: "electric";
      frunkSpace: number;
      bootSpace: number;
    }
  | { kind: "combustion"; bootSpace: number };

function getTotalCarStorage(car: Car) {
  switch (car.kind) {
    case "combustion":
      return car.bootSpace;
    case "electric":
      return car.bootSpace + car.frunkSpace;
    default:
      const _exceptionCase: never = car;
      return _exceptionCase;
  }
}
```

Now if you update add another type to the Car type union. TS will give you an error:



![TS Error 2](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mpi15op3ia96b01uu3ip.png)


How awesome is that 😎 ?

Now some of you reading this might be wondering how can this be helpful if we are talking to a REST API that we don’t control.

Well, that's where `Zod` has your back!

Let's look at an example.

Assume there is an endpoint that provides weather information like this:

```tsx
GET https://someweatherapi.com/en/{location}

// Sample Response ->

// if the provided location is supported:

{
  available: 1,
  humidity: 100,
  temperature: 35,
}

// if provided location is not supported

{
	available: 0
}

```

Now at first glance we can come with a client code to consume the API like this:

```tsx
type WeatherInfo = {
  available: number;
  humidity?: number;
  temparature?: number;
};
async function getWeatherInfo(location: string) {
  const weatherResponse = await fetch(
    `https://someweatherapi.com/en/${location}`
  );
  if (!weatherResponse.ok) {
    throw new Error(weatherResponse.statusText);
  }

  // Spot 1
  const weatherInfo: WeatherInfo = await weatherResponse.json();

  // We could just as easily missed to check and access the properties directly!
  if (weatherInfo.available === 1) {
    return {
      humidty: weatherInfo.humidity,
      temparature: weatherInfo.temparature,
    };
  }
}

// We can consume it like this

async function main() {
  const currentWeatherInfo = await getWeatherInfo("london");

  // Spot 2
  if (
    currentWeatherInfo &&
    currentWeatherInfo.temparature &&
    currentWeatherInfo.humidty
  ) {
    console.log(`Temperature in London is: ${currentWeatherInfo.temparature}`);
    console.log(`Humidity in London is: ${currentWeatherInfo.humidty}`);
  }
}
```

The above code looks fine on the surface. But if you observe the consumer code you notice you end up writing a lot of checks to make sure data is available. Like in `Spot 2`.

Now we can fix the “defensiveness” part of the code with Unions!

Lets refactor:

```tsx
// We can be strict about availabe being 1 or 0! - we don't have to say it's a
// number!
type WeatherInfo =
  | {
      available: 1;
      humidity: number;
      temparature: number;
    }
  | { available: 0 };

async function getWeatherInfo(location: string) {
  const weatherResponse = await fetch(
    `https://someweatherapi.com/en/${location}`
  );
  if (!weatherResponse.ok) {
    throw new Error(weatherResponse.statusText);
  }
  // Spot 1
  const weatherInfo: WeatherInfo = await weatherResponse.json();
  if (weatherInfo.available === 0) {
    throw new Error("Weather information not available!");
  }
  return {
    humidty: weatherInfo.humidity,
    temparature: weatherInfo.temparature,
  };
}

async function main() {
  const currentWeatherInfo = await getWeatherInfo("london");
  // No checks necessary! 😄 - You need to handle the error though.
  console.log(`Temperature in London is: ${currentWeatherInfo.temparature}`);
  console.log(`Humidity in London is: ${currentWeatherInfo.humidty}`);
}
```

**Tip**: If you have a lot of defensive code all over your codebase it's worth reviewing your Type definitions 😄 . With TypeScript this becomes a code smell.

But that’s not all there is to it. If we observe Spot 1 the line of code where we define response as WeatherInfo. That only works at compile time!

After all, it's an API that we don’t control. If the data coming from the network doesn’t fit the Type we have created the App won’t work as expected!

So how do we get around this?

Enter `Zod`!

`Zod` at its core a schema validation library when combined with TS it becomes more like Superman instead of General Zod 😄.

Lets refactor again shall we:

```tsx
import { z } from "zod";

const availableWeatherInfo = z.object({
  available: z.literal(1),
  humidity: z.number(),
  temparature: z.number(),
});

const unavailableWeatherInfo = z.object({
  available: z.literal(0),
});

// https://zod.dev/?id=discriminated-unions
const WeatherInfoResponse = z.discriminatedUnion("available", [
  availableWeatherInfo,
  unavailableWeatherInfo,
]);

// if you hover - you will notice that the model is same as version we created
// earlier.
type WeatherInfo = z.infer<typeof WeatherInfoResponse>;

async function getWeatherInfo(location: string) {
  const weatherResponse = await fetch(
    `https://someweatherapi.com/en/${location}`
  );
  if (!weatherResponse.ok) {
    throw new Error(weatherResponse.statusText);
  }

  // Parsing via Zod instead of the earlier approach.
  const weatherInfo = WeatherInfoResponse.parse(await weatherResponse.json());
  if (weatherInfo.available === 0) {
    throw new Error("Weather information not available!");
  }
  return {
    humidty: weatherInfo.humidity,
    temparature: weatherInfo.temparature,
  };
}

async function main() {
  const currentWeatherInfo = await getWeatherInfo("london");
  console.log(`Temperature in London is: ${currentWeatherInfo.temparature}`);
  console.log(`Humidity in London is: ${currentWeatherInfo.humidty}`);
}
```

Now tell me - that isn’t cool 😎 !

With this version of code - Its TypeSafe at compile time and at **_runtime_**.

At runtime if the API response doesn’t conform to our modelled schema it would throw an `error`!

Just to showcase how this looks in practice if API response comes like this:

```tsx
GET https://someweatherapi.com/en/delhi

// Wrong Response ->
{
	available: 1,
}
```

`Zod` will throw an error! Which will save you from showing `temperature` as `undefined` 😄.

So if you are consuming the `API` in a React component we can wrap it around `Error Boundary` and make sure the potential failure is localised and doesn’t crash the whole application!

So to conclude, `Zod` :

- Aligns really well with TS.
- Using `infer` you don’t have to repeat yourself in TS.
- Gives you runtime and compile time safety with the help of TS!

Code Samples:

{% embed https://github.com/varenya/weather-blog %}


Thats it, thanks for reading - I hope it was useful!