TypeScript 中有一个内置的工具类型 Partial,可以将类型的所有属性变成可选的:
const obj = Partial<{ test: number }> = { test: 1 }
obj.test = undefined // OK
但是,这个工具类型只能将对象的第一层属性变成可选的,更深层次的就没作用了:
const obj = Partial<{ test: { deep: number } }> = { test: { deep: 1 } }
obj.test.deep = undefined // Error
然而在实际项目中,大部分情况的对象都是更深层次的,所以我更希望能有一个 DeepPartial。
我在以下两个项目中找到了 DeepPartial:
在尝试了这两个项目的 DeepPartial 之后,我发现它们都不符合我的需求,以致于最后我只能自己写了一个。
先说说我的需求
我希望对一个 JSON 对象进行深层次的读写,例如:
import pick from 'lodash/pick'
import merge from 'lodash/merge'
const json = {
num: 1,
str: 'string',
strArr: ['a', 'b']
jsonArr: [{ id: 'string' }]
}
// 读取部分数据
function get(path: string): MyDeepPartial<typeof json> {
return pick(json, path)
}
// 写入部分数据
function set(data: MyDeepPartial<typeof json>): void {
merge(json, data)
}
但是,我还希望数组内的类型不要被推断为 undefined,而这就是前面两个项目都不能满足我的地方。
为什么我不希望数组内的类型被加上 undefined?
因为我在写如下代码时,undefined 的存在会报错:
const map: { [id: string]: any } = {
'ID_FOR_STH': { a: '...', b: '...' }
}
get('jsonArr').jsonArr?.map(item => {
// 由于 item.id 被 DeepPartial 加上了 undefined 类型,
// 所以这里 TypeScript 会报错,说 undefined 不能应用于 string
return map[item.id]
})
为什么不满足?
type-fest 会给数组内的原始值和对象属性都加上 undefined:
import type { PartialDeep } from 'type-fest'
const partialJSON: PartialDeep<typeof json> = {}
partialJSON.strArr = [undefined] // TypeScript 没报错,但我希望它报错
ts-essentials 倒是不会给原始值组成的数组加上 undefined,但如果是对象组成的仍然会加:
import type { DeepPartial } from 'ts-essentials'
const partialJSON: DeepPartial<typeof json> = {}
partialJSON.strArr = [undefined] // TypeScript 报错了,这就是我希望的
partialJSON.jsonArr = [{ id: undefined }] // 我希望 id: undefined 会报错,但是 TypeScript 没有
自己动手写一个
没办法,看来只能自己写一个了。我首先看了 ts-essentials 的 DeepPartial 源码,看到这坨代码就头大:
/** Like Partial but recursive */
export type DeepPartial<T> = T extends Builtin
? T
: T extends Map<infer K, infer V>
? Map<DeepPartial<K>, DeepPartial<V>>
: T extends ReadonlyMap<infer K, infer V>
? ReadonlyMap<DeepPartial<K>, DeepPartial<V>>
: T extends WeakMap<infer K, infer V>
? WeakMap<DeepPartial<K>, DeepPartial<V>>
: T extends Set<infer U>
? Set<DeepPartial<U>>
: T extends ReadonlySet<infer U>
? ReadonlySet<DeepPartial<U>>
: T extends WeakSet<infer U>
? WeakSet<DeepPartial<U>>
: T extends Array<infer U>
? T extends IsTuple<T>
? { [K in keyof T]?: DeepPartial<T[K]> }
: Array<DeepPartial<U> | undefined>
: T extends Promise<infer U>
? Promise<DeepPartial<U>>
: T extends {}
? { [K in keyof T]?: DeepPartial<T[K]> }
: IsUnknown<T> extends true
? unknown
: Partial<T>;
但是多看几遍就能找到关键点了:
T extends Array<infer U> ? ... : Array<DeepPartial<U> | undefined>
这段代码将数组内的类型 infer U 变成了 DeepPartial<U>,而当 U 是对象的时候,下面这段代码就将对象内的所有属性都变成了可选的:
T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
所以,改造的关键在于遇到数组类型的时候就原样返回。再考虑到我想要应用的类型只包含 JSON 允许的类型,所以最终我改造完毕的类型是这样的:
type JSONDeepPartialExcludeArray<T> = T extends
| string
| number
| boolean
| null
| unknown[]
? T
: T extends object
? {
[K in keyof T]?: JSONDeepPartialExcludeArray<T[K]>
}
: Partial<T>
测试之后,完美满足了我的需求:
import pick from 'lodash/pick'
import merge from 'lodash/merge'
const json = {
num: 1,
str: 'string',
strArr: ['a', 'b']
jsonArr: [{ id: 'string' }]
}
// 读取部分数据
function get(path: string): JSONDeepPartialExcludeArray<typeof json> {
return pick(json, path)
}
const map: { [id: string]: any } = {
'ID_FOR_STH': { a: '...', b: '...' }
}
get('jsonArr').jsonArr?.map(item => {
// item.id 被识别为仅为 string 类型,所以没有报错
return map[item.id]
})