Introduction#
fetch("https://jsonplaceholder.typicode.com/todos/1")
.then((response) => response.json())
.then((json) => console.log(json));
When you use the above code to make a network request, you will find that your IDE cannot provide any type hints about json
. This is reasonable because no one knows what format of data this interface will return. So you need to follow these steps:
- Manually test this interface to get the return value.
- Based on the content of the return value, use tools like transform.tools to generate corresponding type definitions.
- Copy the type definitions to your project.
- Use
as
to indicate the type of the return value.
By doing this, you can only get the type of json
when the interface returns normally, but there are still many problems to be solved:
- Manually requesting the return value is not accurate enough, for example, the type of the return value may change when the interface call fails.
- There is no type hint for the requested
url
and input parameters. - When the backend interface changes, tsc cannot give an error.
Basic Solution#
There is a very useful feature in ts called 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 modification of the variable will be prohibited. Here is a simple example, you can see that the parameter of the navigateTo
function becomes safer, and 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 basis for achieving type-safe fetch.
Define Route Structure#
First, we need to define a route structure that contains all the necessary information of an interface (request path, input, output, and possible error codes). At the same time, we use zod
here to perform 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 the interfaces in index.ts
.
// index.ts
export * as Add from "./add";
Then we can get all the route information.
import * as routesWithoutPrefixObj from "./interface/index";
const routesWithoutPrefix = Object.values(
routesWithoutPrefixObj
) as ValueOfObjectArray<typeof routesWithoutPrefixObj>;
Define Common Types#
Define a common output structure, common prefix, and unknown error code. Instead of directly returning data from the interface, we return an object that contains the error code 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>;
Transform Route Structure#
So far, we have only obtained an array of all route objects, which is not convenient for implementing 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. In this way, when calling fetch, we can get a better autocompletion experience. After entering the determined path, we can get the correct input type and output type.
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>>
>;
With this, we can get the final type Route
that contains all the route information. The structure of this type is roughly 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<...>
Add Types to fetch#
As you can see, by constraining the first parameter path
of fetch to be keyof Route
and the second parameter to be the input type of the corresponding route, we can get the correct output type. For simplicity, 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 see the usage effect.
Conclusion#
The article ends here. Depending on your needs, you can do the following:
- Combine it with useSWR so that you don't have to wrap each network interface in a separate function.
- Wrap more logic in fetch, such as automatically carrying the token required for login and automatically intercepting routes when the login status expires.
- Handle interfaces that do not involve json interaction separately, such as file upload and download.
- Convince your backend to write interfaces using ts, so you don't have to write these route definitions manually, which is also safer.
If you are interested, here are some type calculations that have been used but not expanded in the article. Of course, this implementation may not be the best. If you have any ideas for improvement, I welcome discussion. 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;