【问题标题】:Simple, typesafe consumption of an API in Typescript在 Typescript 中简单、类型安全地使用 API
【发布时间】:2019-07-05 05:23:31
【问题描述】:

我很难为这个问题命名 - 愿意改变它。

我是 typescript 的新手,我正在尝试以通用、类型安全且非常可扩展的方式使用 API。

RESTyped 获得灵感,我定义了一个通用的“API 定义”接口:

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
            }
        }
    }
}

我怀疑泛型类可以使用这些类型,并为它们提供类型安全的方法。比如:

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 绝对是错误的。我该怎么做?

干杯

编辑 ------------------------------------------

我做了一些阅读,我想我正在取得进展!

这适用于打字稿游乐场:

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 失败?

【问题讨论】:

    标签: typescript api interface type-safety typesafe


    【解决方案1】:

    在得到答案之前,我尝试在 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 类类型的通用约束 可能更有意义,而不是具有一对嵌套索引签名的具体类型:

    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&lt;A&gt;,那么您知道 A 符合您的预期规格(或多或少......我想您可能会考虑确保 A 的每个属性本身都是 object 类型)。

    而且你不需要说MyApi extends ApiSpec。你可以就这样离开它

    interface MyApi { /* ... */ }
    

    如果不好,ApiService 不会接受。或者,如果你想马上知道,你可以这样做:

    interface MyApi extends EnsureAPIMeetsSpec<MyApi> { /* ... */ }
    

    现在定义ApiService。在我们到达那里之前,让我们创建一些我们将很快使用的类型助手。首先,PathsForMethod&lt;A, M&gt; 采用 api 类型 A 和方法名称 M,并返回支持该方法的字符串值路径列表:

    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&lt;T, K&gt;:

    type Lookup<T, K> = K extends keyof T ? T[K] : never;
    

    基本上是T[K],除非编译器无法验证KT 的键,它返回never 而不是给出编译器错误。这将很有用,因为编译器不够聪明,无法意识到A[PathsForMethod&lt;A, "POST"&gt;] 有一个"POST" 键,即使PathsForMethod 是这样定义的。这是我们必须克服的一点问题。

    好的,下面是课程:

    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&lt;A&gt;。然后我们将route 参数限制为仅是PathsForMethod&lt;A, "POST"&gt; 中的那些路径。这将自动排除您在代码中尝试的"/bad" route。最后,我们不能只做A[P]["POST"] 而没有编译器错误,所以我们改用Lookup&lt;A[P], "POST"&gt;,它工作正常:

    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

    【讨论】:

    • 这太棒了!感谢您的帮助——我从中学到了很多花哨的东西,比如PathsForMethod——它基本上就像使用变量进行编程,但它不是变量,而是泛型类型。超级酷!
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-01-24
    • 2022-01-14
    • 1970-01-01
    • 1970-01-01
    • 2021-08-23
    相关资源
    最近更新 更多