Making impossible states impossible ft. Zod and Typescript

Making impossible states impossible ft. Zod and Typescript

Table of contents

No heading

No headings in the article.

Some patterns in the typed functional world 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 lifted from a great talk given by Richard Feldman:

Now the above talk showcases a language called Elm.

Can you believe that the app's 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 modeled the Car type as:

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:

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:

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:

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:

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;
  }
}

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

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
}

Now at first glance, we can come up 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}`);
  }
}

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!

Let's 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}`);
}

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 the 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 😄.

Let's 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}`);
}

Now tell me - that isn’t cool 😎 !

With this version of code - It's TypeSafe at compile time and 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 the API response comes like this:

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 localized and doesn’t crash the whole application!

So to conclude, Zod :

  • Aligns 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 from the post:

That's it, thanks for reading - I hope it was useful!