Fetch runtime type-safe data from Sanity using io-ts

TypeScript is great. It reduces bugs related to mismatching types, it makes it easier to refactor our code, and helps us with nice auto completion. To name a few benefits. But as we know, since our browsers don't understand TypeScript, everything ultimately gets compiled down to JavaScript. This means during runtime, we loose all our type checking abilities. If we fetch som data from an api endpoint, we have to assume that the response is of the same shape as the types we modelled in TypeScript. Wouldn't it be nice if we could check the shape of the response data, and runtime validate that we get what we expect? This way we would be 100% sure that we only handle data with shapes we expect, in our application. io-ts will help us with this!

For my example I'm using Sanity as my endpoint, but any api will work.

First of I create this factory function that let's me easily create type-safe getter functions. This function does some extra work to give extra helpful error messages. It both reports error with the groq query syntax, and reports exactly what (if any) fields that had incorrect types.

 
// lib/requests.ts

import sanityClient from "@sanity/client";
import { parse } from "groq-js";
import * as D from "io-ts/Decoder";
import * as E from "fp-ts/Either";
import * as TE from "fp-ts/TaskEither";

export function makeRequest<A>(
  query: string,
  transform: (input: any) => any,
  decoder: D.Decoder<unknown, A>
): TE.TaskEither<Error, A> {
  return TE.tryCatch(
    async () => {
      /** First we parse the groq query to check if it's valid. If not throw helpful error message. */
      try {
        parse(query);
      } catch (e: any) {
        throw new Error(
          `${e}\n\n${query.slice(0, e.position) +
          "<error here>" +
          query.slice(e.position)
          }`
        );
      }

      /** Fetch the result */
      const result = await client.fetch(query);
      /** Optionally transform the result before we decode it (if not needed just pass the identity function `(x) => x`). */
      const transformed = transform(result);
      /** Decode the result using the given decoder */
      const decoded = decoder.decode(transformed);

      /** Left means we have an error. throw helpful error message. */
      if (E.isLeft(decoded)) {
        throw new TypeError(D.draw(decoded.left));
      }

      /** Success! */
      return decoded.right;
    },
    /** Catch errors */
    (error: unknown): Error =>
      error instanceof Error ? error : new Error(JSON.stringify(error))
  );
}

Then I need to create my type. It's a bit strange syntax to begin with, but this accomplishes two things at once. It creates the decoder, and it creates the regular TypeScript type.

 
import * as D from "io-ts/Decoder";

export const Product = D.struct({
  _id: D.string,
  title: D.string,
});
export type Product = D.TypeOf<typeof Product>;

Using my factory function, and my Product type, I can now very easily create a getter function:

 
// networking/products.ts
import * as D from "io-ts/Decoder";
import { makeRequest } from "lib/requests";
import { Product } from "types";

export const getProducts = makeRequest(
  /** Your query */
  `*[_type == "product"] {
      _id,
      title
    }`,
  /** Optionally transform the response */
  (i) => i,
  /** Validate exact type */
  D.array(Product)
);

Now I can use getProducts() throughout my application and I will know that the response is runtime type-safe. For instance, to create an api endpoint in Next.js, I use it like this:

 
import { NextApiRequest, NextApiResponse } from "next";
import * as E from "fp-ts/Either";
import * as T from "fp-ts/Task";
import { pipe } from "fp-ts/function";
import { Product } from "types";
import { getProducts } from "networking";

export default async (
  _: NextApiRequest,
  res: NextApiResponse<Array<Product>>
) =>
  pipe(
    getProducts,
    T.map(E.fold((v) => res.status(400).end(v.message), res.status(200).json))
  )();

Below are some screenshots of the API in use.

Successful, typesafe fetch

Successful, and type-safe response! 🎉

The response from Sanity was missing the field `_id`

My response was missing the required _id field, but luckily I get very informative error messages. 🤗

The query had a spelling error. The api gave a helpful response.

In this case, there was a typo in my query, but once again I get a very nice error message. 🤩

Addendum

Often I need to "massage" the response before I pass it to the decoder, that's why you can pass in a transform function. If you don't need it, just pass the identity function (x) => x. With images in Sanity, I often need to transform the response, something like this:

 
function transform(i: any) {
  return {
    ...i,
    image: {
      ...i.image,
      width: i.image.metadata.dimensions.width,
      height: i.image.metadata.dimensions.height,
      lqip: i.image.metadata.lqip,
    },
  };
}

Another thing to note is that the decoder will strip away any extra data from the response, which isn't present in the type. For instance if the Sanity response above had included { _id, title, price }, it would just include { _id, title } after being passed through the decoder.

If you have some feedback about this article, you can reach me on Twitter. 😊