打字稿推断联合类型而不是指定类型

Typescript inferring union type instead of specified type

我想将 "metadata" 嵌入到一个类型中以用于创建类型安全的 REST 客户端。这个想法是使用 link 中的类型元数据来推断在 API 调用中使用的正确端点模式。例如。

type Schema = {
  users: {
    GET: {
      query: { userId: string };
    };
  };
  posts: {
    POST: {};
  };
};

type User = {
  self: Link<"users">;
};

const user: User = { self: "https://..." };

http(user.self, "GET", { userId: 1 });

我能够使用强力条件类型来实现这一点。

例如

type Routes = "users" | "posts";
type Verbs<R> = R extends "users" ? "GET" : never;
type Query<R, V> = R extends "users"
  ? V extends "GET"
    ? { queryId: string }
    : never
  : never;

但这会导致难以手动输入的规范化类型模型。相反,我想使用非规范化类型,例如

type Schema = {
  users: {
    GET: {
      query: { userId: string };
    };
  };
  posts: {
    POST: {};
  };
};

使用这样的类型:

type Query<
  S,
  RN extends keyof S,
  VN extends keyof S[RN]
> = OpQuery<S[RN][VN]>;

除最后和关键位外,我能够完成大部分工作,从 link 类型推断路由名称:

type Schema = {
  users: {
    GET: {
      query: { userId: string };
    };
  };
  posts: {
    POST: {};
  };
};

type Link<R extends keyof Schema> = string;

type LinkRouteName<L> = L extends Link<infer R> ? R : never;

type name = LinkRouteName<Link<"users">>;

预计:姓名==="users"

实际:姓名==="users" | "posts"

TypeScript 的类型系统是 structural 而不是名义上的,这意味着决定其身份的是类型的 shape,而不是 name 的类型。像

这样的类型别名
type Link<R extends keyof Schema> = string

不以任何方式定义依赖于 R 的类型。 Link<"users">Link<"posts"> 的计算结果都是 string;它们只是同一类型的不同名称,因此不会对类型系统产生影响。从理论上讲,这两种类型彼此无法区分......有些情况下编译器 可能 区分两种形状相同的类型,例如不同的名称,但你永远不应该依赖它。

反正R类型的信息丢出去了,下面的也带不回来了:

type LinkRouteName<L> = L extends Link<infer R> ? R : never;

LinkRouteName<Link<"users">>LinkRouteName<Link<"posts">> 都计算为 LinkRoutName<string>,从中无法确定超过 [=26= 中 R 的一般约束]定义:即keyof Schema、a.k.a。 "users" | "posts"。 TypeScript FAQ 有一个 similar example 类型推断无法恢复丢弃的类型信息。


所以,如果你想区别对待两种类型,它们应该有不同的结构。如果 Link<R> 是一个对象类型,我建议向那个名为 name 的对象添加一个 属性,其值类型为 R

但是您只是在使用原始 string 类型。在运行时实际上不可能使原始类型在结构上有所不同(您不能像 (var a = ""; a.prop = 0;) 那样向其添加属性)。您 可以 使用 String wrapper type 并根据需要向其添加属性。

另一种方法是通过使用名为“branded primitives”的东西,误导编译器将原始 string 类型的值视为在结构上与 string 不同的值.您将原始类型与幻影 "brand" 属性 相交,以用于区分类型。我的建议是:

type Link<S extends keyof Schema> = string & { __schema?: S };

幻数属性是可选的,所以你可以写

const userLink: Link<"users"> = "anyStringYouWant";

没有 type assertion,但您必须确保手动注释类型。以下将不起作用:

const userLink = "anyStringYouWant";

那只是 string 而不是 Link<"users">


一旦你有了它,剩下的就应该到位了。 http() 函数的可能声明可以是:

declare function http<
  S extends keyof Schema,
  V extends keyof Schema[S],
  >(
    url: Link<S>,
    verb: V,
    ...[query]: Schema[S][V] extends { query: infer Q } ? [Q] : []
  ): void;

使用 rest tuple types 表示 http() 可能会或可能不会采用第三个参数,具体取决于相应的 Schema 条目是否具有相关的 query 属性.

让我们验证这是否有效:

type User = { self: Link<"users"> };
const user: User = { self: "https://..." };
http(user.self, "GET", { userId: "1" }); // okay
http(user.self, "GET", {}); // error!  userId missing
http(user.self, "GET"); // error! expected 3 arguments

type Post = { self: Link<"posts"> }
const post: Post = { self: "https://..." }
http(post.self, "POST"); // okay 
http(post.self, "POST", { userId: "1" }); // error! expected 2 arguments

我觉得不错。希望有所帮助;祝你好运!

Link to code