DEV Community

Cover image for Making impossible states impossible ft. Zod and Typescript
Varenya Thyagaraj
Varenya Thyagaraj

Posted on • Edited on

Making impossible states impossible ft. Zod and Typescript

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:

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:

type Car = {
  isElectric: boolean;
  isCombustion: boolean;
  frunkSpace?: number;
  bootSpace: number;
};
Enter fullscreen mode Exit fullscreen mode

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

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;
}
Enter fullscreen mode Exit fullscreen mode

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

Well what if by mistake someone passed the object as:

const porscheTaychan: Car = {
  isCombustion: true,
  isElectric: true,
  frunkSpace: 10,
  bootSpace: 200,
};
Enter fullscreen mode Exit fullscreen mode

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:

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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

TS Error 1

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:

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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

TS Error 2

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:

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
}

Enter fullscreen mode Exit fullscreen mode

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

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}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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}`);
}
Enter fullscreen mode Exit fullscreen mode

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:

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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:

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

// Wrong Response ->
{
    available: 1,
}
Enter fullscreen mode Exit fullscreen mode

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:

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

Top comments (0)