TypeScript类型高级技巧-利用函数重载、条件类型判断、类型映射、类型解构和类型递归来创造一个完美的Object.assign()类型推断

255 阅读4分钟

起因

这个事情还得从一个常用于合并对象的方法:Object.assign()说起,它的作用是合并所有传入的对象到第一个对象中,并返回第一个对象。

如何为这样的方法写类型呢?

  • 首先,它的返回值取决于参数,那么肯定得是一个泛型
  • 其次,它的入参数量不固定,那么一定需要剩余参数符号...
  • 最后,它的返回值等于所有对象合并的结果,我们理应想到交叉类型符号&

但问题在于它的入参数量不固定,我们怎么处理这种不同入参带来的不同的类型结果呢?

函数重载

以下是TS lib es2015对Object.assign()的类型推断:

interface ObjectConstructor {
    assign<T extends {}, U>(target: T, source: U): T & U;
    assign<T extends {}, U, V>(target: T, source1: U, source2: V): T & U & V;
    assign<T extends {}, U, V, W>(target: T, source1: U, source2: V, source3: W): T & U & V & W;
    assign(target: object, ...sources: any[]): any
}

它内部的确是使用了交叉类型符号&来合并对象,并且利用了函数重载。不过我们一眼就能看出来,它只能对4个及以下的调用推断出类型,多了就会匹配assign(target: object, ...sources: any[]): any的重载,也就是返回any。因为入参的数量不固定,我们不可能为每种情况都做重载,但如果不用重载,又没办法去合并类型,这个事情非常矛盾。

当然了实际上我们多数情况下也用不到那么多对象的合并,这样的类型看起去也能凑合...

我们假设有这样一种情况:

const a = Object.assign({ b: true }, { b: 1 })
a.b.toFixed() // Property toFixed does not exist on type { b: true; } & { b: number; }

这是什么情况呢?

// 因为
const a: { b: true } & { b: number }
// 所以
b: true & number

true & number这玩意一看就不存在啊所以a.b实际上不是任何类型。

现在再来看Object.assign()的类型你会发现它是漏洞百出,还不如直接AnyScript呢。那能不能让后面的属性覆盖前面的呢?诶,这个可以有

类型映射&条件类型判断

通过类型映射条件类型判断,我们可以实现一个这样的工具泛型。

type Assign<T, U> = {
  [K in keyof T | keyof U]: K extends keyof U ? U[K] : K extends keyof T ? T[K] : never
}

这个泛型传入两个对象,通过对象映射,先判断key是不是存在第二个对象中,如果是,则返回第二个对象的相应属性,否则返回第一个对象的相应属性,这就跟Object.assign()的实际效果是一样的了。

但是,它接受的是两个对象类型,而不是一个对象类型的数组,我们还是无法避免使用烦人的函数重载。那么能不能我们就接受一个对象数组类型,然后再从数组类型里面取出指定位置的元素呢?诶,你是不是正在寻找:

类型解构

通过类型解构,我们可以从数组类型中提取元素类型

type AssignMultiple<T extends any[]> = T extends [ infer A, infer B ] ? Assign<A, B> : never

但是,这样的泛型还是只能处理数组的前两个元素,那么如何处理后续的元素呢?你应该需要的是:

类型递归

虽然数组类型不能遍历,但是可以递归

注意,通过在泛型的内部引用自身,就可以达到递归的效果,但是,递归的泛型必须使用类型条件表达式来防止无限递归,否则将会引发静态错误。这样的技巧叫做条件递归类型

type Children<T> = T extends { children: infer C } ? Children<C> : T;

const back = <T>(param: T): Children<T> => param as unknown as any

const a = back({
  children: {
    children: {
      children: {
        a: 1
      }
    }
  }
}) // const a: { a: number }

这是一个用递归条件类型来获取children直到没有这个属性为止的例子。

如果把遍历比作二维平面,那么递归就是三维立体世界,遍历可以做的事,递归同样可以。

我们使用类型解构中的例子,利用剩余参数符号来接收剩下的未处理的类型,然后再次丢给AssignMultiple做剩下的处理。

type AssignMultiple<T extends object[]> = 
    T extends [ infer A, infer B, ...infer C ] ? AssignMultiple<[ Assign<A, B>, ...C ]> : T[0]
    
const back = <T extends object[]>(...params: T): AssignMultiple<T> => params as unknown as any

const a = back({ b: '' }, { b: 1, c: 2 }, { b: { c: 1 } })

a.b // b: { c: number }
a.c // c: number

瞧,它完美的符合Object.assign()的效果!

让我们用自定义的Object.assign()推断替换掉内置的类型推断:

declare global {
  interface ObjectConstructor {
    assign<T extends object[]>(...sources: T): AssignMultiple<T>
  }
}

至此,我们已经探索了函数重载条件类型判断类型映射类型解构类型递归以及使用了infer关键字,这些都是TS高阶的类型技巧,一般作为普通开发者用不到这些技巧,只有类库和框架开发者操作频繁地使用这些技巧做类型判断,但还是那句话,技多不压身,况且如果你掌握了这些技巧,那么说明你对TypeScript已经非常熟悉了,你可以大方自信地在简历上写上 “精通TypeScript”