网站搜索

typescript - 如何正确定义这种模式的类型?


我写了一些遵循这种模式的代码:

  • 一个端点接收一些数据,这些数据可以是几种预定义对象类型之一。例如,假设我们有三个不同的对象,分别称为 Contact、Connection 和 Friend。每一个对象的结构完全不同,但都需要进行相似的逻辑处理,将数据转换为相同类型的输出。因此,我写了三个不同的函数如下:
const transformContact = (data: Contact): Record<string, any> => {return {name: "mike", id: 123}};
const transformConnection = (data: Connection): Record<string, any> => {return {name: "alex", id: 124}}
const transformFriend = (data: Friend): Record<string, any> => {return {name: "sarah", id: 125}}

interface Contact {
  name: string;
  phoneNumber: string;
}

interface Connection {
  firstName: string;
  lastName: string;
  cellNumber: string;
}

interface Friend {
  address: string;
  nickname: string;
}

然后我有一个函数返回一个转换函数,用于端点的其余部分,这样我就不必在其余的代码中基于输入进行检查:

type ObjectType = 'Contact' | 'Connection' | 'Friend';

export const getDocFunction = (type: ObjectType): ReturnType => {
  switch (type) {
    case 'Contact':
      return transformContact;
    case 'Connection':
      return transformConnection;
    case 'Friend':
      return transformFriend;
  }
}

我已经这样定义了 ReturnType:

type ReturnType = 
((data: Contact) => Record<string, any>) |
((data: Connection) => Record<string, any>) |
((data: Friend) => Record<string, any>)

现在,在我的端点中,当我收到一些数据时,我知道它要么是 Contact 类型,要么是 Connection 类型,或者是 Friend 类型

type Data = Contact | Connection | Friend;

但是,当我调用 getDocFunction 时,它返回的函数实际上需要像这样一个类型参数:

type ActuallyNeeded = Contact & Connection & Friend;

我有点理解这一点,因为如果代码说接收到的数据只是三种可能类型中的一种,但函数只接受某一种特定类型,就会抛出错误。所以这是有道理的,但我不喜欢的是,将数据类型标记为三种类型的联合只是简单地表示数据,并不准确地反映数据本身。

这对我来说算是可行的,因为我在端点代码中所做的只是将数据传递给转换函数,然后就完成了。但是如果将来我必须做更多操作,那么智能感知提示我数据拥有三种可能接口的所有属性就显得不太正确,因为它实际上只会遵循其中一个接口。

你有没有什么建议可以使这个模式更清晰或以某种方式改进类型定义?另外,作为一个附加问题,这种类型的模式是否有正式名称?如果有的话,我想对它做一些研究。

编辑:这里有一个带有正确代码的 TypeScript playground:

答案#1

你遇到的根本问题是,你有一个单一的“event”对象,其 body 是一个 Contact、Connection 或 Friend,同时还包含一个 type 属性,该属性是字符串字面量类型之一:"Contact"、"Connection" 或 "Friend"……但是你的数据结构并没有表示这些之间的对应关系。就编译器而言,事件体可能有一个 type 为 "Friend",但实际上是 Connection 类型。也就是说,它正在处理两个不相关的联合类型:(Contact | Connection | Friend) & {type: "Contact" | "Connection" | "Friend"}。

所以当你用 body.type 查找一个 docFunction 时,你会得到三种可能函数之一,然后当你尝试将 body 作为参数传递时,编译器会担心这样做不安全。"如果你传递的是一个 Connection 给某个结果实际上是 transformFriend() 的函数怎么办?" 它唯一可以确定的是安全的情况是,你传递的参数同时是 Contact、Connection 和 Friend,因为那样无论是什么函数都能正常工作。这就是为什么错误信息会提示它不能确定你的 Contact | Connection | Friend 实际上是 Contact & Connection & Friend 的原因。

事实上,这种错误通常是提醒我注意与相关联合类型相关的潜在问题的那种情况。有一次,我在 microsoft/TypeScript#30581 提出了一个问题,寻求一种比简单放弃更好的处理方法(例如,使用类型断言向编译器撒谎)。


最终,microsoft/TypeScript#47109 被合并了,其中描述了一种使用泛型以获得更好行为的重构技术。这个想法是将类型显式地写为某些键集合上的映射类型,并使用其中一个键作为泛型类型参数来索引它们,这样你想要支持的操作就可以被看作是一个简单的泛型操作。

在你的示例代码案例中,我可能会这样做:


首先,构建一种以最简单的方式表示你数据的键值映射的类型:

interface DataMap {
  Contact: Contact,
  Connection: Connection,
  Friend: Friend
}

然后,开始给明确映射到这个类型的事物指定类型:

const docFunction: { [K in keyof DataMap]: (data: DataMap[K]) => Record<string, any> } = {
  Contact: transformContact,
  Connection: transformConnection,
  Friend: transformFriend
}
export const getDocFunction = <K extends keyof DataMap>(type: K) => {
  return docFunction[type];
}

所以 getDocFunction 接受一个泛型类型 K 的 type,并返回一个对应泛型类型的函数 (data: DataMap[K]) => Record<string, any>,其方式是对 K 中的联合类型进行分配。

现在让我们把事件 body 属性描述为类似的分布式映射类型:

type Body<K extends keyof DataMap = keyof DataMap> =
  { [P in K]: { type: P } & DataMap[P] }[K];

你可以希望说服自己,例如,Body<"Contact"> 是 {type: "Contact"} & Contact,并且 Body<keyof DataMap> 是这三种类型的联合。

最后,我们可以编写一个通用的 processEvent() 函数,它接受一个 Body<K> 并调用正确的转换函数:

function processEvent<K extends keyof DataMap>(event: { body: Body<K> }) {
  const transformFunc = getDocFunction(event.body.type);
  const transformedData = transformFunc(event.body);
  return transformedData;
}

这是可行的,因为 transformFunc 被视为等同于类型 (data: DataMap[K]) => Record<string, any>,而 event.body 可以赋值给 DataMap[K],所以函数被认为可以接受 event.body,并且编译时不会报错。

你可以使用任何 Body 类型(甚至是联合类型)调用 processEvent(),它都会成功:

processEvent({
  body: {
    type: "Contact",
    name: "abc",
    phoneNumber: "123"
  }
})

代码的 Playground 链接