fetch("https://jsonplaceholder.typicode.com/todos/1")
.then((response) => response.json())
.then((json) => console.log(json))
当你直接使用如上的代码来发起网络请求时,你会发现你的 IDE 无法给你提供任何关于 json
的类型提示。这很合理,没人知道这个接口会返回什么格式的数据。于是你需要进行如下几个步骤:
as
来标识返回值的类型这样下来你也只是能获得接口正常返回时 json
的类型而已,还有很多问题需要被解决:
url
和输入的参数没有任何类型提示ts 中有个非常好用的功能是 as const
断言,给变量添加这个断言后,变量的类型会被推断为更具体的字面量类型,同时会禁止对变量的修改。下面是一个简单的例子,可以看到 navigateTo
这个函数的参数变的更加安全了,我们不能传递一个未被定义的路由名称字符串。
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"'.
这就是我们能实现类型安全的 fetch 的基础。
首先我们需要定义一个路由结构,这个结构包含了一个接口的全部必要信息(请求路径,输入,输入,以及可能出现的错误码)。同时这里我们使用 zod
来进行运行时的数据格式校验。
// 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
定义多个接口之后,在 index.ts
中导出所有的接口。
// index.ts
export * as Add from "./add"
然后我们就能拿到全部的路由信息
import * as routesWithoutPrefixObj from "./interface/index"
const routesWithoutPrefix = Object.values(
routesWithoutPrefixObj,
) as ValueOfObjectArray<typeof routesWithoutPrefixObj>
定义通用返回结构,公共前缀和未知错误码。我们并不是接口直接返回数据,而是返回一个包含了错误码和数据的对象。
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
}
计算出带前缀的实际路由
export const routes = routesWithoutPrefix.map((r) => {
return {
...r,
path: `${prefix}${r.path}`,
}
}) as unknown as AddPathPrefixForRoutes<typeof routesWithoutPrefix>
到此为止,我们只是拿到了全部路由对象组成的数组,这对于我们实现类型安全的 fetch 来说并不方便。我们需要将这个数组转换成一个对象,这个对象的 key 是路由的请求路径,value 是路由的其它信息。如此,调用 fetch 时,我们就能获得更好的补全体验,在输入确定的路径之后,能得到正确的输入类型和输出类型。
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>>
>
如此我们就能得到最终包含全部路由信息的类型 Route
,这个类型的大致结构如下:
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<...>
可以看到,只需要约束 fetch 的第一个参数 path 为 keyof Route
,第二个参数为对应路由的输入类型,就能得到正确的输出类型。为了实现上的简单,所有的 http 请求都使用 POST
方法,所有的输入参数都从 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,让我们来看看使用效果吧。
文章写到这里就算是结束了,取决你自己的需求,你还可以做如下事情:
如果你感谢兴趣,以下是一些被用到但是没有在文章中展开的类型计算。当然,这份实现未必是最好的,如果你有任何改进的想法,欢迎与我讨论,非常感谢。
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