在微服务大行其道的今天,前端工程师经常需要聚合数据,你的上游只会返回一堆 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)
这里有几点需要注意的地方
- 代码中使用了
as never
来告诉TypeScript忽略类型检查,这并不优雅,但是暂时也没好的办法,TS 类型体操太复杂了,我们主要目的为了在使用时有好的类型提示 - 代码中有很多地方抛出错误, 这也是弥补类型检测的不足,通过抛出错误,来实现运行时的类型安全,你可以根据自己的需求进行调整
结语
希望这篇文章能帮助你更好地理解 TypeScript 的类型体操,并在实际开发中应用这些技巧来提高代码的可读性和可维护性。通过使用这些高级类型,你可以编写出更灵活、更强大的数据处理函数,减少手动类型断言和判空的麻烦。 我是Bob,如果你喜欢这篇文章,请点赞, 如果有TS/Monorepo相关问题,欢迎交流。