在 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.POSTbody 属性,这不是错误。


那么我们该如何解决这个问题呢?我认为将 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] 除非编译器无法验证 KT 的键,它 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

这就是我开始的方式。之后您可能想要缩小 ApiSpecApiRoute 的定义范围。例如,您可能需要两种类型的 ApiRoute,其中一些需要 body,而另一些则禁止。并且您可以将您的 http 方法表示为一些字符串文字的联合,例如 "POST" | "GET" | "PUT" | "DELETE" | ... 并缩小 ApiSpec 以便 "POST" 方法需要 body"GET" 方法禁止它等。这可能会使编译器更容易确保您只在正确的路径上调用 post() 并且需要和定义此类帖子的 body 而不是可能未定义。

无论如何,希望对您有所帮助;祝你好运!

Link to code