lmk123 / blog

个人技术博客,博文写在 Issues 里。
https://github.com/lmk123/blog/issues
623 stars 35 forks source link

TypeScript 中的 DeepPartial #111

Open lmk123 opened 2 years ago

lmk123 commented 2 years ago

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 源码,看到这坨代码就头大:

https://github.com/ts-essentials/ts-essentials/blob/e0ded0bba5a18148dcbae9cc4306ac8173cb2e65/lib/types.ts#L32-L57

/** 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]
})