在 Typescript 中简单、类型安全地使用 API
Simple, typesafe consumption of an API in Typescript
我很难为这个问题命名 - 愿意改变它。
我是打字稿的新手,我正在尝试以通用、类型安全且可扩展的方式使用 API。
从 RESTyped 获得灵感,我定义了一个通用的 "API definition" 接口:
interface ApiBase {
[route: string]: ApiRoute
}
interface ApiRoute {
query: { [key: string]: string }
body: any
response: any
}
interface ApiSpec {
[route: string]: {
[method: string]: ApiRoute
}
}
这可用于定义多个 API 端点的类型,如下所示:
interface MyApi extends ApiSpec {
"/login": {
"POST": {
body: {
username: string,
password: string
},
response: {
token: string
}
}
},
"/user": {
"GET": {
query: {
"username": string
},
response: {
"email": string,
"name": string
}
}
}
}
我怀疑泛型 class 可以使用这些类型,并为它们提供类型安全的方法。类似于:
const api = ApiService<MyApi>();
api.post("/login", {
// This body is typesafe - won't compile if it doesn't match the spec
username: "johnny99",
password: "hunter2"
});
如果对象与 MyApi
接口中定义的 body
不匹配,post()
方法将无法编译。
不幸的是,我不知道从这里去哪里。像这样:
class ApiService<T> {
post(route: string, body: T[route].body): T[route].response {
// todo
}
}
这显然不能编译。如何访问 MyApi
接口中的子类型? T[route].body
肯定是错的。我该怎么做?
干杯
编辑----------------------------------------
我读了一些书,我想我有所收获!
这适用于 typescript playground:
class ApiService<API extends ApiSpec> {
async post<Path extends Extract<keyof API, string>>(
route: Path,
data: API[Path]["POST"]["body"]
): Promise<API[Path]["response"]> {
const resp = await fetch(route, {
method: "POST",
body: JSON.stringify(data),
});
return await resp.json();
}
}
并且在调用存在的路由时完美运行:
const api = new ApiService<MyApi>();
// Will give an error if the wrong "body" is passed in!
api.post("/login", {
username: "johnny99",
password: "rte"
});
但它也在调用不存在的路由时起作用,这不是我想要发生的。
// Should error, but doesn't!
api.post("/bad", {
whatever: ""
});
我也有点担心我的 post()
实现 – 当 resp.json()
给出的对象与类型定义中定义的对象不同时会发生什么?它会抛出运行时错误吗?我应该总是在 try/catch
守卫中调用它,还是我可以以某种方式让 Promise 失败?
在找到答案之前,我尝试在 Playground 中重现您的情况,并注意到我需要将 ApiRoute
的类型更改为
interface ApiRoute {
query?: { [key: string]: string }; // optional
body?: any; // optional
response: any;
}
以避免错误。如果这对您来说不是错误,那是因为您没有使用 --strictNullChecks
,您确实应该使用它。我假设我们从现在开始进行严格的空值检查。
我认为你的问题是你的 ApiSpec
接口说它有 ApiRoute
属性用于 每个可能的键 和 每个可能的子项:
declare const myApi: MyApi;
myApi.mumbo.jumbo; // ApiRoute
myApi.bad.POST.body; // any
该代码不是错误。所以,当你打电话给
api.post("/bad", {
whatever: ""
});
你实际上只是在查找一些 myApi.bad.POST
的 body
属性,这不是错误。
那么我们该如何解决这个问题呢?我认为将 ApiSpec
的类型表达为 泛型约束 可能更有意义 MyApi
-like 类型而不是具有一对的具体类型嵌套索引签名:
type EnsureAPIMeetsSpec<A extends object> = {
[P in keyof A]: { [M in keyof A[P]]: ApiRoute }
};
那是一个 mapped type,它将 A
像 {foo: {bar: number, baz: string}}
变成了 {foo: {bar: ApiRoute, baz: ApiRoute}}
。所以如果你有一个 A extends EnsureAPIMeetsSpec<A>
,那么你知道 A
符合你的预期规格(或多或少......我想你可能会考虑确保 A
的每个 属性本身就是 object
类型)。
而且你不需要说 MyApi extends ApiSpec
。你可以像
一样留下它
interface MyApi { /* ... */ }
如果它不好,它不会被 ApiService
接受。或者,如果你想马上知道,你可以这样做:
interface MyApi extends EnsureAPIMeetsSpec<MyApi> { /* ... */ }
现在定义ApiService
。在我们到达那里之前,让我们制作一些我们将很快使用的类型助手。首先,PathsForMethod<A, M>
采用 api 类型 A
和方法名称 M
,以及 returns 支持该方法的 string-valued 路径列表:
type PathsForMethod<A extends EnsureAPIMeetsSpec<A>, M extends keyof any> = {
[P in keyof A]: M extends keyof A[P] ? (P extends string ? P : never) : never
}[keyof A];
然后 Lookup<T, K>
:
type Lookup<T, K> = K extends keyof T ? T[K] : never;
基本上是 T[K]
除非编译器无法验证 K
是 T
的键,它 returns never
而不是给出编译器错误。这将很有用,因为编译器不够聪明,无法意识到 A[PathsForMethod<A, "POST">]
有一个 "POST"
键,即使那是 PathsForMethod
的定义方式。这是我们必须克服的一个难题。
好的,这是 class:
class ApiService<A extends EnsureAPIMeetsSpec<A>> {
async post<P extends PathsForMethod<A, "POST">>(
route: P,
data: Lookup<A[P], "POST">["body"]
): Promise<Lookup<A[P], "POST">["response"]> {
const resp = await fetch(route, {
method: "POST",
body: JSON.stringify(data)
});
return await resp.json();
}
}
再看一遍...我们将 A
限制为 EnsureAPIMeetsSpec<A>
。然后我们将 route
参数限制为仅 PathsForMethod<A, "POST">
中的那些路径。这将自动排除您在代码中尝试的 "/bad"
route
。最后,我们不能只执行 A[P]["POST"]
而不会出现编译器错误,因此我们改为执行 Lookup<A[P], "POST">
,并且效果很好:
const api = new ApiService<MyApi>(); // accepted
const loginResponse = api.post("/login", {
username: "johnny99",
password: "rte"
});
// const loginResponse: Promise<{ token: string; }>
api.post("/bad", { // error!
whatever: ""
}); // "/bad" doesn't work
这就是我开始的方式。之后您可能想要缩小 ApiSpec
和 ApiRoute
的定义范围。例如,您可能需要两种类型的 ApiRoute
,其中一些需要 body
,而另一些则禁止。并且您可以将您的 http 方法表示为一些字符串文字的联合,例如 "POST" | "GET" | "PUT" | "DELETE" | ...
并缩小 ApiSpec
以便 "POST"
方法需要 body
而 "GET"
方法禁止它等。这可能会使编译器更容易确保您只在正确的路径上调用 post()
并且需要和定义此类帖子的 body
而不是可能未定义。
无论如何,希望对您有所帮助;祝你好运!
我很难为这个问题命名 - 愿意改变它。
我是打字稿的新手,我正在尝试以通用、类型安全且可扩展的方式使用 API。
从 RESTyped 获得灵感,我定义了一个通用的 "API definition" 接口:
interface ApiBase {
[route: string]: ApiRoute
}
interface ApiRoute {
query: { [key: string]: string }
body: any
response: any
}
interface ApiSpec {
[route: string]: {
[method: string]: ApiRoute
}
}
这可用于定义多个 API 端点的类型,如下所示:
interface MyApi extends ApiSpec {
"/login": {
"POST": {
body: {
username: string,
password: string
},
response: {
token: string
}
}
},
"/user": {
"GET": {
query: {
"username": string
},
response: {
"email": string,
"name": string
}
}
}
}
我怀疑泛型 class 可以使用这些类型,并为它们提供类型安全的方法。类似于:
const api = ApiService<MyApi>();
api.post("/login", {
// This body is typesafe - won't compile if it doesn't match the spec
username: "johnny99",
password: "hunter2"
});
如果对象与 MyApi
接口中定义的 body
不匹配,post()
方法将无法编译。
不幸的是,我不知道从这里去哪里。像这样:
class ApiService<T> {
post(route: string, body: T[route].body): T[route].response {
// todo
}
}
这显然不能编译。如何访问 MyApi
接口中的子类型? T[route].body
肯定是错的。我该怎么做?
干杯
编辑----------------------------------------
我读了一些书,我想我有所收获!
这适用于 typescript playground:
class ApiService<API extends ApiSpec> {
async post<Path extends Extract<keyof API, string>>(
route: Path,
data: API[Path]["POST"]["body"]
): Promise<API[Path]["response"]> {
const resp = await fetch(route, {
method: "POST",
body: JSON.stringify(data),
});
return await resp.json();
}
}
并且在调用存在的路由时完美运行:
const api = new ApiService<MyApi>();
// Will give an error if the wrong "body" is passed in!
api.post("/login", {
username: "johnny99",
password: "rte"
});
但它也在调用不存在的路由时起作用,这不是我想要发生的。
// Should error, but doesn't!
api.post("/bad", {
whatever: ""
});
我也有点担心我的 post()
实现 – 当 resp.json()
给出的对象与类型定义中定义的对象不同时会发生什么?它会抛出运行时错误吗?我应该总是在 try/catch
守卫中调用它,还是我可以以某种方式让 Promise 失败?
在找到答案之前,我尝试在 Playground 中重现您的情况,并注意到我需要将 ApiRoute
的类型更改为
interface ApiRoute {
query?: { [key: string]: string }; // optional
body?: any; // optional
response: any;
}
以避免错误。如果这对您来说不是错误,那是因为您没有使用 --strictNullChecks
,您确实应该使用它。我假设我们从现在开始进行严格的空值检查。
我认为你的问题是你的 ApiSpec
接口说它有 ApiRoute
属性用于 每个可能的键 和 每个可能的子项:
declare const myApi: MyApi;
myApi.mumbo.jumbo; // ApiRoute
myApi.bad.POST.body; // any
该代码不是错误。所以,当你打电话给
api.post("/bad", {
whatever: ""
});
你实际上只是在查找一些 myApi.bad.POST
的 body
属性,这不是错误。
那么我们该如何解决这个问题呢?我认为将 ApiSpec
的类型表达为 泛型约束 可能更有意义 MyApi
-like 类型而不是具有一对的具体类型嵌套索引签名:
type EnsureAPIMeetsSpec<A extends object> = {
[P in keyof A]: { [M in keyof A[P]]: ApiRoute }
};
那是一个 mapped type,它将 A
像 {foo: {bar: number, baz: string}}
变成了 {foo: {bar: ApiRoute, baz: ApiRoute}}
。所以如果你有一个 A extends EnsureAPIMeetsSpec<A>
,那么你知道 A
符合你的预期规格(或多或少......我想你可能会考虑确保 A
的每个 属性本身就是 object
类型)。
而且你不需要说 MyApi extends ApiSpec
。你可以像
interface MyApi { /* ... */ }
如果它不好,它不会被 ApiService
接受。或者,如果你想马上知道,你可以这样做:
interface MyApi extends EnsureAPIMeetsSpec<MyApi> { /* ... */ }
现在定义ApiService
。在我们到达那里之前,让我们制作一些我们将很快使用的类型助手。首先,PathsForMethod<A, M>
采用 api 类型 A
和方法名称 M
,以及 returns 支持该方法的 string-valued 路径列表:
type PathsForMethod<A extends EnsureAPIMeetsSpec<A>, M extends keyof any> = {
[P in keyof A]: M extends keyof A[P] ? (P extends string ? P : never) : never
}[keyof A];
然后 Lookup<T, K>
:
type Lookup<T, K> = K extends keyof T ? T[K] : never;
基本上是 T[K]
除非编译器无法验证 K
是 T
的键,它 returns never
而不是给出编译器错误。这将很有用,因为编译器不够聪明,无法意识到 A[PathsForMethod<A, "POST">]
有一个 "POST"
键,即使那是 PathsForMethod
的定义方式。这是我们必须克服的一个难题。
好的,这是 class:
class ApiService<A extends EnsureAPIMeetsSpec<A>> {
async post<P extends PathsForMethod<A, "POST">>(
route: P,
data: Lookup<A[P], "POST">["body"]
): Promise<Lookup<A[P], "POST">["response"]> {
const resp = await fetch(route, {
method: "POST",
body: JSON.stringify(data)
});
return await resp.json();
}
}
再看一遍...我们将 A
限制为 EnsureAPIMeetsSpec<A>
。然后我们将 route
参数限制为仅 PathsForMethod<A, "POST">
中的那些路径。这将自动排除您在代码中尝试的 "/bad"
route
。最后,我们不能只执行 A[P]["POST"]
而不会出现编译器错误,因此我们改为执行 Lookup<A[P], "POST">
,并且效果很好:
const api = new ApiService<MyApi>(); // accepted
const loginResponse = api.post("/login", {
username: "johnny99",
password: "rte"
});
// const loginResponse: Promise<{ token: string; }>
api.post("/bad", { // error!
whatever: ""
}); // "/bad" doesn't work
这就是我开始的方式。之后您可能想要缩小 ApiSpec
和 ApiRoute
的定义范围。例如,您可能需要两种类型的 ApiRoute
,其中一些需要 body
,而另一些则禁止。并且您可以将您的 http 方法表示为一些字符串文字的联合,例如 "POST" | "GET" | "PUT" | "DELETE" | ...
并缩小 ApiSpec
以便 "POST"
方法需要 body
而 "GET"
方法禁止它等。这可能会使编译器更容易确保您只在正确的路径上调用 post()
并且需要和定义此类帖子的 body
而不是可能未定义。
无论如何,希望对您有所帮助;祝你好运!