TypeScript类型体操:实现智能数据聚合函数的类型提示优化

在微服务大行其道的今天,前端工程师经常需要聚合数据,你的上游只会返回一堆 ID, 你需要在前端或者BFF进行聚合,取出这些ID 对应的Name, 有些人会将常用的数据聚合封装成一个通用函数, 但在实际编写时就会发现, TS 类型处理很痛苦,本文就用一些 TS 类型体操来解决这个问题。最后会给出一个通用的聚合函数Demo, 你可以基于自己的情况简单改改直接使用。

大多数人的写法

这种写起来相对简单, 但是类型提示不友好, 能写出这种写法的朋友属于已经 TS 类型体操入门的水平

ts 复制代码
type EnrichableKeys = 'userId' | 'classId' | 'dockerId'
type ReturnEnrichData<T> = T & {
  userName?: string
  className?: string
  dockerName?: string
}
// 4. 定义 enrichData 函数
function enrichData<T extends Partial<Record<EnrichableKeys, string>>>(list: T[], options: EnrichableKeys[]): ReturnEnrichData<T>[] {
  // ...函数实现省略
  return list as never
}

这种写法不友好的地方在于,当我只想聚合userName时,其他的name 字段也都被提示了, 而且还是可选的, 这就导致了在使用时, 需要手动进行类型断言或者判空。

更好的写法

以下写法都解决了当只传入userId时, 只会提示userName, 其他的name 字段都不会提示, 这就避免了手动类型断言和判空的问题。我个人更推荐第二种。

动态计算类型

更好的写法 1

ts 复制代码
type EnrichableKeys = 'userId' | 'classId' | 'dockerId'

type AddedNames<T> = (T extends { userId: true } ? { userName: string } : unknown) &
  (T extends { classId: true } ? { className: string } : unknown) &
  (T extends { dockerId: true } ? { dockerName: string } : unknown)

function enrichData<T extends Partial<Record<EnrichableKeys, string>>, K extends Partial<Record<EnrichableKeys, boolean>>>(
  list: T[],
  options: K,
): (T & AddedNames<K>)[] {
  // 忽略函数内容
  return list as never
}

这种写法的核心在于 Partial<Record<AllKeys, boolean>>, 会对 options 参数做类型收缩, 将boolean类型收缩为 true, 这样 AddedNames<K> 就可以根据传入的参数动态生成返回类型。 这种写法的好处是读起来简单,容易理解, 缺点是 options 参数需要传入一个对象, 不能直接传入一个数组。

更好的写法 2 (推荐)

ts 复制代码
type EnrichableKeys = 'userId' | 'classId' | 'dockerId'

interface KeyToAddedNameMap {
  userId: { userName: string }
  classId: { className: string }
  dockerId: { dockerName: string }
}

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void
  ? I
  : never

type AddedNames<T extends readonly EnrichableKeys[]> = UnionToIntersection<
  KeyToAddedNameMap[T[number]]
>

function enrichData<
  T extends Partial<Record<EnrichableKeys, string>>,
  const K extends readonly EnrichableKeys[],
>(list: T[], options: K): (T & AddedNames<K>)[] {
  // 函数实现...
  return list as never
}

这个写法的核心是先通过const K extends readonly EnrichableKeys[] 的到一个元组类型, 然后通过 UnionToIntersection 将联合类型转换为交叉类型, 最终得到一个动态的 AddedNames<K> 类型。 这种写法的好处是options可以直接传入一个数组, 也可以传入一个元组。

完整 Demo 及注意点

ts 复制代码
// 1. 定义映射表
type EnrichableKeys = 'userId' | 'classId' | 'dockerId'

interface KeyToAddedNameMap {
  userId: { userName: string }
  classId: { className: string }
  dockerId: { dockerName: string }
}

// 2. 将联合类型转为交叉类型的工具
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void
  ? I
  : never

// 3. 定义核心的 AddedNames 类型
type AddedNames<T extends readonly EnrichableKeys[]> = UnionToIntersection<
  KeyToAddedNameMap[T[number]]
>

function enrichData<
  T extends Partial<Record<EnrichableKeys, string>>,
  const K extends readonly EnrichableKeys[],
>(list: T[], options: K): (T & AddedNames<K>)[] {
  const optionsSet = new Set(options)
  const hasUserId = optionsSet.has('userId')
  const hasClassId = optionsSet.has('classId')
  const hasDockerId = optionsSet.has('dockerId')

  const userMap = new Map<string, string>()
  if (hasUserId) {
    const userIds = list.map((item) => item.userId).filter((p) => p != null)
    const mockList = getMockListByIds(userIds)
    for (const item of mockList) {
      userMap.set(item.id, item.name)
    }
  }
  const classMap = new Map<string, string>()
  if (hasClassId) {
    const classIds = list.map((item) => item.classId).filter((p) => p != null)
    const mockList = getMockListByIds(classIds)
    for (const item of mockList) {
      classMap.set(item.id, item.name)
    }
  }
  const dockerMap = new Map<string, string>()
  if (hasDockerId) {
    const dockerIds = list.map((item) => item.dockerId).filter((p) => p != null)
    const mockList = getMockListByIds(dockerIds)
    for (const item of mockList) {
      dockerMap.set(item.id, item.name)
    }
  }
  return list.map((item) => {
    const newItem: Record<string, string> = {}
    if (hasUserId) {
      if (item.userId == null) {
        throw new Error('userId not found')
      }
      const userName = userMap.get(item.userId)
      if (userName == null) {
        throw new Error('userName not found')
      }
      newItem.userName = userName
    }
    if (hasClassId) {
      if (item.classId == null) {
        throw new Error('classId not found')
      }
      const dockerName = classMap.get(item.classId)
      if (dockerName == null) {
        throw new Error('className not found')
      }
      newItem.dockerName = dockerName
    }
    if (hasDockerId) {
      if (item.dockerId == null) {
        throw new Error('dockerId not found')
      }
      const dockerName = dockerMap.get(item.dockerId)
      if (dockerName == null) {
        throw new Error('dockerName not found')
      }
      newItem.dockerName = dockerName
    }
    return {
      ...item,
      ...newItem,
    } as never
  })
}

function getMockListByIds(ids: string[]) {
  console.info('ids', ids)
  const mockList = [
    {
      id: '1',
      name: '测试名称',
    },
  ]
  return mockList
}

// ===== 测试 =====
const list = [
  {
    id: '1',
    userId: '1',
    classId: '1',
  },
]

const result = enrichData(list, ['userId', 'classId'])
console.info(result)

这里有几点需要注意的地方

  1. 代码中使用了as never来告诉TypeScript忽略类型检查,这并不优雅,但是暂时也没好的办法,TS 类型体操太复杂了,我们主要目的为了在使用时有好的类型提示
  2. 代码中有很多地方抛出错误, 这也是弥补类型检测的不足,通过抛出错误,来实现运行时的类型安全,你可以根据自己的需求进行调整

结语

希望这篇文章能帮助你更好地理解 TypeScript 的类型体操,并在实际开发中应用这些技巧来提高代码的可读性和可维护性。通过使用这些高级类型,你可以编写出更灵活、更强大的数据处理函数,减少手动类型断言和判空的麻烦。 我是Bob,如果你喜欢这篇文章,请点赞, 如果有TS/Monorepo相关问题,欢迎交流。

相关推荐
十盒半价6 小时前
TypeScript + React:大型项目开发的黄金搭档
前端·typescript·trae
时节18 小时前
Gemini CLI 代码解析
typescript
GDAL1 天前
SWC 深入全面讲解
typescript·swc
Xi_Xu2 天前
DeepLX:终极免费高性能 DeepL API 替代方案
typescript·serverless
前端工作日常2 天前
ESLint 配置深度解析:parserOptions、env 和 flowtype 的核心作用与实战指南
typescript·eslint
前端拿破轮2 天前
2025年了,你还不知道怎么在vscode中直接调试TypeScript文件?
前端·typescript·visual studio code
天文家3 天前
关于TypeScript中type和interface的区别与作用场景
typescript
lichenyang4533 天前
Axios封装以及添加拦截器
前端·javascript·react.js·typescript
太阳上的雨天3 天前
与 TRON (波场) 区块链进行交互的命令行工具 (CLI): tstroncli
typescript·区块链·交互·tron·trx·trc20