Hyoban

Hyoban

Don’t do what you should do, do you want.
x
github
telegram
follow
email

How to make fetch type-safe

Introduction#

fetch("https://jsonplaceholder.typicode.com/todos/1")
  .then((response) => response.json())
  .then((json) => console.log(json));

When you directly use the code above to initiate a network request, you will find that your IDE cannot provide any type hints for json. This is reasonable, as no one knows what format of data this interface will return. Therefore, you need to follow these steps:

  1. Manually test this interface to obtain the return value.
  2. Based on the content of the return value, use tools like transform.tools to generate the corresponding type definitions.
  3. Copy the type definitions into your project.
  4. Use as to indicate the type of the return value.

Even after this, you can only obtain the type of json when the interface returns normally, and there are still many issues that need to be resolved:

  1. Manually requesting the return value is not accurate enough; for example, when there is an error calling the interface, the type of the return value may change.
  2. The requested url and input parameters have no type hints.
  3. When the backend interface changes, tsc cannot provide an error.

Foundation of the Solution#

A very useful feature in TypeScript is the as const assertion. After adding this assertion to a variable, the type of the variable will be inferred as a more specific literal type, and modifications to the variable will be prohibited. Here is a simple example, where you can see that the parameters of the navigateTo function become safer; we cannot pass an undefined route name string.

const routes = {
  home: "/",
  about: "/about",
} as const;

declare function navigateTo(route: keyof typeof routes): void;

navigateTo("404");
// Argument of type '"404"' is not assignable to parameter of type '"home" | "about"'.

This is the foundation for achieving type-safe fetch.

Defining Route Structure#

First, we need to define a route structure that contains all the necessary information for an interface (request path, input, output, and possible error codes). Here, we also use zod for runtime data format validation.

// add.ts
import { z } from "zod";

export const path = "/add" as const;

export const input = z.object({
  a: z.number(),
  b: z.number(),
});

export const data = z.object({
  result: z.number(),
});

export const errCode = ["NO_LOGIN"] as const;

After defining multiple interfaces, export all interfaces in index.ts.

// index.ts
export * as Add from "./add";

Then we can obtain all the route information.

import * as routesWithoutPrefixObj from "./interface/index";

const routesWithoutPrefix = Object.values(
  routesWithoutPrefixObj
) as ValueOfObjectArray<typeof routesWithoutPrefixObj>;

Defining Common Types#

Define a common return structure, common prefix, and unknown error codes. We do not return data directly from the interface; instead, we return an object that contains error codes and data.

const routesWithoutPrefix = Object.values(
  routesWithoutPrefixObj
) as ValueOfObjectArray<typeof routesWithoutPrefixObj>;

export const prefix = "/api";
export type Prefix = typeof prefix;
export const unknownError = "UNKNOWN_ERROR" as const;

export type OutputType<T, Err extends readonly string[]> =
  | {
      err: ArrayToUnion<Err> | typeof unknownError;
      data: null;
    }
  | {
      err: null;
      data: T;
    };

Calculate the actual routes with prefixes.

export const routes = routesWithoutPrefix.map((r) => {
  return {
    ...r,
    path: `${prefix}${r.path}`,
  };
}) as unknown as AddPathPrefixForRoutes<typeof routesWithoutPrefix>;

Converting Route Structure#

Up to this point, we have only obtained an array composed of all route objects, which is not convenient for us to achieve type-safe fetch. We need to convert this array into an object, where the key is the request path of the route, and the value is the other information of the route. This way, when calling fetch, we can have a better completion experience, obtaining the correct input and output types after entering a determined path.

type RouteItem = {
  readonly path: string;
  input: z.AnyZodObject;
  data: z.AnyZodObject;
  errCode: readonly string[];
};

type Helper<T> = T extends RouteItem
  ? Record<
      T["path"],
      {
        input: z.infer<T["input"]>;
        data: z.infer<T["data"]>;
        errCode: T["errCode"];
        output: OutputType<z.infer<T["data"]>, T["errCode"]>;
      }
    >
  : never;

export type DistributedHelper<T> = T extends RouteItem ? Helper<T> : never;

export type Route = UnionToIntersection<
  DistributedHelper<ArrayToUnion<typeof routes>>
>;

Thus, we can obtain the final type Route that contains all route information. The general structure of this type is as follows:

type Route = Record<"/api/add", {
    input: {
        a: number;
        b: number;
    };
    data: {
        result: number;
    };
    errCode: readonly ["NO_LOGIN"];
    output: OutputType<{
        result: number;
    }, readonly ["NO_LOGIN"]>;
}> & Record<"/api/dataSet/delDataSet", {
    ...;
}> & ... 13 more ... & Record<...>

Adding Types to Fetch#

As you can see, by constraining the first parameter path of fetch to keyof Route and the second parameter to the corresponding route's input type, we can obtain the correct output type. For simplicity in implementation, all HTTP requests use the POST method, and all input parameters are passed from the body.

export const myFetch = async <Path extends keyof Route>(
  path: Path,
  input: Route[Path]["input"]
) => {
  try {
    const res = await fetch(path, {
      method: "POST",
      headers: headers,
      body: input instanceof FormData ? input : JSON.stringify(input),
    });
    const data = await (res.json() as Promise<Route[Path]["output"]>);
    if (data.err) {
      throw new CustomError(data.err);
    }
    return data.data as Route[Path]["data"];
  } catch (err) {
    if (err instanceof CustomError) {
      throw err;
    }

    if (err instanceof Error) {
      throw new CustomError(err.message);
    }

    throw new CustomError(unknownError);
  }
};

class CustomError<T extends string> extends Error {
  errorCode: T;

  constructor(msg: string) {
    super(msg);
    Object.setPrototypeOf(this, CustomError.prototype);
    this.errorCode = msg as T;
  }
}

Nice, let's take a look at the usage effect.

ScreenShot 2023-08-09 12.38.53.gif

Conclusion#

The article ends here. Depending on your needs, you can also do the following:

  1. Combine it with useSWR so that you don't have to wrap functions for each network interface separately.
  2. Encapsulate more logic in fetch, such as automatically carrying the token required for login and automatically routing interception when the login status expires.
  3. Handle interfaces that do not interact with JSON separately, such as file uploads and downloads.
  4. Convince your backend to write interfaces for you using TypeScript, so that these route definitions do not need to be written manually and are safer.

If you are interested, here are some type calculations that were used but not elaborated on in the article. Of course, this implementation may not be the best, and if you have any improvement ideas, feel free to discuss them with me. Thank you very much.

export type ValueOfObjectArray<
  T,
  RestKey extends unknown[] = UnionToTuple<keyof T>
> = RestKey extends []
  ? []
  : RestKey extends [infer First, ...infer Rest]
  ? First extends keyof T
    ? [T[First], ...ValueOfObjectArray<T, Rest>]
    : never
  : never;

// https://github.com/type-challenges/type-challenges/issues/2835
type LastUnion<T> = UnionToIntersection<
  T extends any ? (x: T) => any : never
> extends (x: infer L) => any
  ? L
  : never;

type UnionToTuple<T, Last = LastUnion<T>> = [T] extends [never]
  ? []
  : [...UnionToTuple<Exclude<T, Last>>, Last];

type AddPrefix<T, P extends string = ""> = T extends RouteItem
  ? {
      path: `${P}${T["path"]}`;
      input: T["input"];
      data: T["data"];
      errCode: T["errCode"];
    }
  : never;

export type AddPrefixForArray<Arr> = Arr extends readonly []
  ? []
  : Arr extends readonly [infer A, ...infer B]
  ? [AddPrefix<A, Prefix>, ...AddPrefixForArray<B>]
  : never;

export type DistributedHelper<T> = T extends RouteItem ? Helper<T> : never;

export type ArrayToUnion<T extends readonly any[]> = T[number];

export type UnionToIntersection<U> = (
  U extends any ? (k: U) => void : never
) extends (k: infer I) => void
  ? I
  : never;
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.