Typescript学习(十三)工具类型进阶

前面我们学习了属性修饰工具类型、结构工具类型、集合工具类型、模式匹配工具类型, 那么现在我们来依次深化这几种工具类型, 来应对更加复杂的场景;

属性修饰工具类型进阶

属性修饰方面, 我们的深化方向有2:

  1. 一个是深层属性修饰, 之前只是第一层属性被修饰, 而无法深入到属性的属性;
  2. 指定特定的属性成员, 对他们进行修饰, 而不仅仅是所有属性被修饰;

深度修饰

如果要实现深度修饰, 就像深拷贝一样, 可以深入到属性的属性, 深拷贝, 我们通常会使用递归的方式, 同理, 对于深度修饰, 通常也可以使用递归的逻辑:

例如, 之前介绍过的内置工具类型Partial, 它只是让对象类型的第一层属性变为可选

type Partial<T> = {
  [P in keyof T]?:T[P]
}

如果这时候来一个这样的对象, 显然Partial就无能为力了

interface Person {
  name: string;
  age: number;
  healthInfo: {
    heartRate: number; // 心率
    BP: number; // 血压
    BS: number; // 血糖
    isHasGeneticDisease: boolean; // 是否有遗传疾病
  }
}

type Result = Partial<Person>
/**
 * 执行结果
 * {
    name?: string | undefined;
    age?: number | undefined;
    healthInfo?: {
        heartRate: number;
        BP: number;
        BS: number;
        isHasGeneticDisease?: boolean | undefined;
    } | undefined;
}
 */

可以看到, 此时healthInfo内的属性还是必填; 我们来尝试使用递归的方式解决:

// 通过递归来进行深度属性修饰
type deepPartial<T extends {}> = {
  [K in keyof T]+?: T[K] extends {} ? deepPartial<T[K]> : T[K]
}
type PartialPerson = deepPartial<Person>

// 正常运行
let demo1:PartialPerson = {
  name: 'jack',
}
let demo2:PartialPerson = {
  healthInfo: {
    isHasGeneticDisease: false
  }
}

此时, 我们利用工具类型deepPartial创建了一个新的类型PartialPerson, 它内部的属性, 全部都是可选的! 此时, 我们再想想, 如何将其再转为必填, 毕竟内置的工具类型一般都成对儿, 比如: Partial和Required; 因此, 既然有deepPartial, 那自然也应该有deepRequired:

type deepRequired<T extends {}> = {
  [K in keyof T]-?: T[K] extends {} ? deepRequired<T[K]> : T[K]
}

试想一下这样行不行? 整体上跟deepPartial一样, 只是deepPartial是+?, 而此处是-?

// 前面得到的PartialPerson是一个全是可选属性的对象, 我们再使用deepRequired,
// 将其重新变回全部必填
type RequiredPerson = deepRequired<PartialPerson>

// 但是, 我们发现, 以下竟然没有报错!
let demo3:RequiredPerson = {
  name: '老明',
  age: 90,
  healthInfo: {
    BP: 100,
    isHasGeneticDisease: false
  }
}

healthInfo明明已经是必填了, 我漏了那么多属性, 怎么不报错呢? 我们来仔细看下,条件类型中 T[K] extends {}, 在T[K]为healthInfo的时候, 是否一定成立? 我们知道PartialPerson中的healthInfo是可选的, 可选的意味着什么? 是不是意味着这个属性的类型其实是{} | undefined类型, 因为它可选啊, 自然就有可能是undefined

那根据我们之前类型层级中学习到的知识来看, {} | undefined extends {} 成立吗?

显然不成立, 既然{} | undefined extends {}不成立, 那么, 当T[K]为healthInfo的时候, T[K] extends {} ? deepRequired<T[K]> : T[K], 最后只会得到T[K]! 那怎么办? 如果才能把这个讨厌的undefined去掉? 我们之前学习过条件类型的分布式特性, 其实可以利用分布式特性这么写:

type NoUndefined<T> = T extends undefined ? never : T

还记得吗? 分布式特性中, 只要这个类型是以泛型参数的形式传入的, 并且, 传入之后它没有被包裹或者处理成别的类型, 那么, 就会触发分布式特性, 即 泛型的每一个成员依次和目标类型对比, 逻辑为true的结果会再次组成一个新的联合类型!

type deepRequired<T extends {}> = {
  [K in keyof T]-?: NoUndefined<T[K]> extends {} ? deepRequired<NoUndefined<T[K]>> : T[K]
}
// 这样, healthInfo的属性, 也被成功转为了必填!
let demo3:RequiredPerson = {
  name: '老明',
  age: 90,
  healthInfo: { // 报错!
    BP: 100,
    isHasGeneticDisease: false
  }
}

于是, 我们就通过NoUndefined, 去掉了undefined类型, 从而使deepRequired功能正常了; 其他的属性修饰型工具类型也类似:

// 深度添加readonly修饰符
type deepReadonly<T extends {}> = {
  readonly [P in keyof T]: T[P] extends {} ? deepReadonly<T[P]> : T[P]
}

type ReadonlyPerson = deepReadonly<Person>

let person:ReadonlyPerson = {
  name: 'jack',
  age: 18,
  healthInfo: {
    heartRate: 100,
    BP: 100,
    BS: 12,
    isHasGeneticDisease: false
  }
}

person.healthInfo.BP = 12 // 报错, 属性为只读

// 深度去除readonly修饰符
type deepNonReadonly<T extends {}> = {
  -readonly [P in keyof T]: T[P] extends {} ? deepNonReadonly<T[P]> : T[P]
}

type nonReadonlyPerson = deepNonReadonly<ReadonlyPerson>

let person2:nonReadonlyPerson = {
  name: 'jack',
  age: 18,
  healthInfo: {
    heartRate: 100,
    BP: 100,
    BS: 12,
    isHasGeneticDisease: false
  }
}

person2.healthInfo.BP = 12 // 成立

还有, 我们前面在处理deepRequired存在undefined问题的时候, 自己写了个NoUndefined工具类型, 这个其实可以深化下, 将其改造为去除undefined和null的工具类型, 这在日常开发中也是颇为常见的:

// 去掉一个类型中的null和undefined
type NoNullUndefined<T> = T extends null | undefined ? never : T
// 定义一个动物类型, 其属性允许undefined或者是null
interface Animal {
  name: string | undefined;
  appearance: {
    color: string | null; // 颜色
    isHasBodyHair: boolean | undefined | null; // 是否有体毛
  }
}
// 深度遍历, 去掉null和undefined
type deepNoEmpty<T extends {}> = {
  [P in keyof T]: T[P] extends {} ? deepNoEmpty<T[P]> : NoNullUndefined<T[P]>
}
// 得到一个不允许为null以及undefined的动物类型
type NoEmptyAnimal = deepNoEmpty<Animal>

// 允许为null或undefined, 运行正常
let dog:Animal = {
  name: undefined,
  appearance: {
    color: null,
    isHasBodyHair: undefined
  }
}

// 不允许为null或者undefined, 运行报错
let cat:NoEmptyAnimal = {
  name: undefined,
  appearance: {
    color: null,
    isHasBodyHair: null
  }
}

部分修饰

说完了深度修饰, 再来想想, 如果我们想修饰一个对象的部分属性, 应该怎么做? 比如, 让一个对象的部分属性为可选, 或者必填; 综合我们之前学习的各种类型工具和工具类型, 思路大体可以是:

  1. 利用结构化工具类型, 诸如pick、omit, 将对象进行拆分, 以我们需要处理的属性组成一个对象, 剩余的属性组成另一个对象;
  2. 利用属性修饰工具类型对我们想要处理的那些属性组成的对象进行修饰;
  3. 利用交叉类型类型工具, 将两个对象进行合并

比如, 我们想要让部分属性可选:

// 1. 拆分
type name = Pick<Person, 'name'>
type age = Omit<Person, 'name'>

// 2. 修饰
type PartialName = Partial<name>

// 3. 合并
type Result = PartialName & age

// 运行正常
let person:Result = {
  age: 12
}

但是, 我们显然是想要写一个通用的工具类型, 而不是每次都分三步, 我们将上面的步骤合并下

// 1. 合并变量, 把什么name/age/PartialName之类的中间变量统统去掉
type Result = Partial<Pick<Person, 'name'>> & Omit<Person, 'name'>
// 2. 将入參抽象化, 我们把可变的部分作为泛型参数传入
type SomePropsPartial<T extends {}, K extends keyof T> = Partial<Pick<T, K>> & Omit<T, K>

let person:SomePropsPartial<Person, 'name'> = {
  age: 12
}

所以, 这样, 我们也写出了一个稍微有点复杂度的工具类型了, 可见, 我们在开发工具类型的时候, 可以采用先分后总的方式, 将每一步处理逻辑都分别写出来, 然后再合并, 就能减少很多的困难;

同样的思路, 我们可以写出其他相关的工具类型

// 部分属性必填
type SomePropsRequired<T extends {}, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>
// 部分属性只读
type SomePropsReadonly<T extends {}, K extends keyof T> = Readonly<Pick<T, K>> & Omit<T, K>

// 部分属性不得为null或undefined
type NoNullUndefined<T> = T extends null | undefined ? never : T
type NoEmpty<T extends {}> = {
  [P in keyof T]: NoNullUndefined<T[P]>
}
type SomePropsNoEmpty<T extends {}, K extends keyof T> = NoEmpty<Pick<T, K>> & Omit<T, K>

这里就不再写下去了, 因为基本规律已经很清楚了都是: 处理所有键的工具类型<Pick<对象类型, 特定的键>> & Omit<对象类型, 特定的键>; 这个其实不用去记, 清楚其推导过程就行了

结构工具类型进阶

以值类型为判断条件创建类型

前面已经实现了对一个对象类型的深度修饰和部分属性修饰, 但是, 它们有个共同特点, 那就是都是以键的类型为判断的出发点, 如果我现在不想以键为判断条件, 而想以值作为条件, 该如何处理? 比如, 我想找出一个对象类型中, 所有值为string类型的属性, 并将它们再重新组成一个新的类型; 该如何处理? 按照前面的分析思路, 我们先理清步骤:

  1. 找出符合条件的属性的键
  2. 将这些键传给Pick工具类型

首先, 看看如何找出符合条件的属性的键? 也就是根据值类型, 找出键名, 之前在学习类型工具的索引类型查询的时候, 我们知道如果索引类型查询是一个联合类型, 那么查询运算的结果也会产生一个类似于条件类型分布式特性的现象

interface Person {
  name: string;
  age: number,
  gender: string,
  height: number
}

// 索引类型查询是联合类型, 则会依次进行查询, 将结果也组成为一个联合类型
type D = Person['name'| 'age'] // string | number

所以, 我们可以通过这种方式来获取符合条件的属性值:

// 如果值符合期望类型, 则返回对应的键!
type GetKeysByValueType<T, ExpectType> = {
  [P in keyof T]-?: T[P] extends ExpectType ? P : never
}[keyof T]
// 获取到值符合条件的键类型
type StringTypeKey = GetKeysByValueType<Person, string> // name | gender

上面说白了就是通过映射类型获取一个键值类型相同的对象类型, 然后通过索引类型查询找出其键组成的联合类型;然后, 将其传给Pick工具类型

type StringTypeObj = Pick<Person, StringTypeKey>
/**
 * type StringTypeObj = {
    name: string;
    gender: string;
   }
 */

我们进一步合并及抽象化, 可以得到:

type PickByValueType<T, expectType> = Pick<T, GetKeysByValueType<T, expectType>>
type Result = PickByValueType<Person, number>
/**
 * type Result = {
    age: number;
    height: number;
  }
 */

完成了PickByValueType, 我们可以依照同样的原理完成OmitByValueType, 其实很简单, 无非就是将前面的GetKeysByValue的逻辑反过来

type FilterKeysByValue<T, ExpectType> = {
  [P in keyof T]-?: T[P] extends ExpectType ? never : P // 逻辑和GetKeysByValueType相反
}[keyof T]

type OmitByValueType<T, ExpectType> = Pick<T, FilterKeysByValue<T, ExpectType>>

type Result = OmitByValueType<Person, number>
/**
 * type Result = {
    name: string;
    gender: string;
  }
 */

既然PickByValueType和OmitByValueType只是一点点逻辑上的相反, 那么我们是否可以将其合并?

type Condition<Value, Expect, Resolve, Reject> = Value extends Expect ? Resolve : Reject

type KeysByValueType<T extends {}, ExpectType, Bool extends boolean> = {
  [P in keyof T]-?: T[P] extends ExpectType ? Condition<Bool, true, P, never> : Condition<Bool, true, never, P>
}[keyof T]

type PickByValueType<T extends {}, ExpectType> = Pick<T, KeysByValueType<T, ExpectType, true>>

type OmitByValueType<T extends {}, ExpectType> = Pick<T, KeysByValueType<T, ExpectType, false>>

type Result1 = PickByValueType<Person, string>
/**
 * type Result1 = {
    name: string;
    gender: string;
  }
*/
type Result2 = OmitByValueType<Person, string>
/**
 * type Result2 = {
    age: number;
    height: number;
  }
*/

以上示例中, 我们将PickByValueType和OmitByValueType逻辑相反的地方, 用Condition工具类型进行了封装, 将不同之处, 通过传入一个参数来区分;

以上案例看上去已经可以满足需求了, 但是如果我们期待的是一个联合类型, 而我们又希望准确地获取那个属性,该怎么处理? 例如: 我的name是string | number类型, 此时我想就获取这个类型, 只有一个string或者只有一个number的属性都不行


interface Person {
  name: string | number;
  age: number,
  gender: string,
  height: number
}

type Result1 = PickByValueType<Person, string | number>
/**
 * type Result1 = {
    name: string | number;
    age: number;
    gender: string;
    height: number;
  }
*/
type Result2 = OmitByValueType<Person, string | number>
/**
 * type Result2 = {}
*/

可以看到, Result1中, 我们获取了所有属性, Result中, 我们又一个都没获取到, 所以, 如何准确地获取呢? 就是说我的Result1应该只有name, 我的Result2应该只缺name ,说白了, 如果传入的泛型是联合类型, 期待的类型也是联合类型, 那肯定会触发分布式特性, 也就是T[P] extends ExpectType, 可能得到的是一个联合类型,而不是一个准确单个类型, 所以我们根据之前学习的知识, 首先, 可以对条件类型做屏蔽分布式的处理

type KeysByValueType<T extends {}, ExpectType, Bool extends boolean> = {
  [P in keyof T]-?: [T[P]] extends [ExpectType] ? Condition<Bool, true, P, never> : Condition<Bool, true, never, P>
}[keyof T]

完成了这一步, 其实还是无法做到精确匹配, 毕竟[1|2] extends [1|2|3], 一样是成立的! 而我们希望的是我想要1|2, 那么就只有1|2才能被匹配到! 所以, 还可以再加一个条件, 那就是反过来比较, 既然[1|2] extends [1|2|3]成立, 那[1|2|3] extends [1|2]肯定不成立, 所以, 我们可以将以上逻辑封装成一个严格全等的工具类型

type StrictEqual<Value, Expect, Resolve, Reject, Fallback = never> = [Value] extends [Expect] ? [Expect] extends [Value] ? Resolve : Reject : Fallback
type Result3 = StrictEqual<1|2|3, 1|2, true, false> // never
type Result4 = StrictEqual<1|2, 1|2|3, true, false> // false

由此, 我们可以对之前的KeysByValueType进行改造

type StrictKeysByValueType<T extends {}, ExpectType, Bool extends boolean> = {
  [P in keyof T]-?: StrictEqual<T[P], ExpectType, Bool extends true ? P : never, Bool extends true ? never : P, Bool extends true ? never : P>
}[keyof T]

type StrictPickByValueType<T extends {}, ExpectType> = Pick<T, StrictKeysByValueType<T, ExpectType, true>>

type StrictOmitByValueType<T extends {}, ExpectType> = Pick<T, StrictKeysByValueType<T, ExpectType, false>>

type Result1 = StrictPickByValueType<Person, string | number>
/**
 * type Result1 = {
    name: string | number;
  }
*/
type Result2 = StrictOmitByValueType<Person, string | number>
/**
 * 
 * age: number;
 * gender: string;
 * height: number;
*/

互斥逻辑

如果我们有一个场景, 某两个类型不得共存, 即 互斥逻辑, 说到互斥, 我们第一想到的自然是联合类型, A | B, 不是A类型, 就是B类型

interface Dog {
  swiming: true
}

interface Bird {
  fly: true
}

type Union = Dog | Bird
// 联合类型可以让游泳和飞同时存在
let obj:Union = {
  swiming: true,
  fly: true
}

显然, 还是不行, 因为这两个联合类型没有发生'矛盾', 即 双方要存在相同的属性, 但是, 其值的属性却不同, 只要能营造这种矛盾点, 联合类型就必然要'二选一', 而无法做到两者'和平共处':

interface Fish {
  swiming: boolean
}

interface Bird {
  fly: boolean
}

type Union = Fish | Bird
// 联合类型可以让游泳和飞同时存在
let obj:Union = {
  swiming: true,
  fly: true
}

type Without<T, U> = {
  [P in Exclude<keyof T, keyof U>]?: never 
}

type A = Without<Fish, Bird> & Bird // {swiming?: never, fly: boolean}
type B = Without<Bird, Fish> & Fish // {swiming: boolean, fly?: boolean}
// 从上面可以知道, A和B已经产生'矛盾'了, 所以下面的联合类型, 也就只允许'二选一'
type C = A | B

let StrangeAnimal:C = { // 报错!
  swiming: true,
  fly: true
}

集合工具类型进阶

前面我们介绍集合工具类型, 在交集、并集、补集、差集的场景中, 我们基本都是处理一些基础数据类型:

// 交集
type insersection<T, U> = T extends U ? T : never
type result1 = insersection<1|2|3, 2|3> // 2|3

// 并集
type union<T, U> = T | U
type result2 = union<1, 2> // 1|2

// 差集
type difference<T, U> = T extends U ? never : T
type result3 = difference<1|2, 2|3> // 1

// 补集
type complementary<T, U extends T> = difference<T, U>
type result4 = complementary<1|2|3, 2|3> // 1

正如以上例子, 都是处理数字, 亦或者字符串, 但是, 如果我们想处理对象, 又该如何进行呢? 根据之前化繁为简的思路, 以及前面利用Pick、Omit等内置工具类型处理对象的经验, 可以将操作思路分解如下:

  1. 对对象的键进行集合操作, 获取相应的键的集合;
  2. 再利用获取到的键结合Pick方法, 得到想要的新对象类型;
// 对象键的交集
type insersectionKeys<T extends objectType, U extends objectType> = insersection<keyof T, keyof U>
// 对象的交集
type insersectionObj<T extends objectType, U extends objectType> = Pick<T, insersectionKeys<T, U>>
type result5 = insersectionObj<{name:string, age: number}, {name:string, gender: boolean}> // {name:string}

// 对象键的差集
type differenceKeys<T extends objectType, U extends objectType> = difference<keyof T, keyof U>
// 对象的差集
type differenceObj<T extends objectType, U extends objectType> = Pick<T, differenceKeys<T, U>>
type result6 = differenceObj<{name:string, age: number}, {name:string, gender: boolean}> // {age:number}

// 对象键的补集
type complementaryKeys<T extends U, U extends objectType> = complementary<keyof T, keyof U>
// 对象补集
type complementaryObj<T extends U, U extends objectType> = Pick<T, complementaryKeys<T, U>>
type result7 = complementaryObj<{name:string, age: number}, {age: number}> // {name:string}

以上就是利用简单的工具类型, 逐步组合, 搭建起了能够适应不同集合场景的工具类型; 大家是否发现了, 还有并集我们没写出来, 因为并集毕竟可能会超出一个对象类型的范围, 我们总不可能去Pick一个对象上不存在的属性吧; 而且并集涉及到一个问题, 那就是不同对象, 相同的属性值类型不同,该如何处理? 因此, 对于并集, 我们得换一个思路;

假设我们有A和B两个对象类型, A类型的权重更高, 我们可以做如下处理:

  1. 先将各自的差集求出
  2. 再根据权重, 求出交集
  3. 合并!
// 对象并集
type unionObj<T extends objectType, U extends objectType> = differenceObj<T, U> & insersectionObj<T, U> & differenceObj<U, T>

interface A {
  name: string;
  age: number;
  job: string;
}

interface B {
  nickName: string;
  age: string;
  skill: string;
}

type result8 = unionObj<A, B>

let person:result8 = {
  name: 'jack',
  nickName: '老6',
  age: 18, // 注意, 此时age只能是number类型了
  skill: 'IT',
  job: 'developer'
}

注意, 上面的操作中differenceObj, 即差集, 是公平的, A和B都做了一次主对象, 但是交集insersectionObj, 明显就是以A为主了; 所以, 交集部分, 是A覆盖了B的age属性的类型, 最终结果为age是number类型

模式匹配工具类型进阶

模式匹配, 前面我们简单介绍了使用infer关键字, 进行类型的部分提取

type functionType = (...args: any) => any
type getFirstParams<T extends functionType> = T extends (firstParams: infer F, ...args: any[]) => any ? F :never

type fn = (name:string, age: number) => void
type result = getFirstParams<fn> // string

我们通过getFirstParams工具类型获取了函数类型的首个参数, 而如果是获取最后一个参数呢? 显然就稍微复杂了一些, 同样, 化繁为简思路:

  1. 确保一个类型是函数类型;
  2. 确保这个函数类型有参数;
  3. 利用扩展运算符, 将参数问题转为数组问题
  4. 提取数组最后一个数
type getLastParams<T extends functionType> = 
  T extends (args: infer S) => void 
  ? S 
  : T extends (...args: infer M) => void 
  ? M extends [...any, infer L] 
    ? L 
    : never
  : never
                      
type result1 = getLastParams<(name:string, age: number) => void> // number
type result2 = getLastParams<(...args: number[]) => void> // number
type result3 = getLastParams<(bool: boolean) => void> // boolean

其实以上的嵌套逻辑就是, 通过嵌套, 逐步接近想要的类型, 并用infer关键字进行逐步提取

全部评论

相关推荐

不愿透露姓名的神秘牛友
11-26 18:54
说等下个版本吧的发呆爱好者很贪睡:佬最后去了哪家呀
点赞 评论 收藏
分享
找不到工作死了算了:没事的,雨英,hr肯主动告知结果已经超越大部分hr了
点赞 评论 收藏
分享
11-09 14:54
已编辑
华南农业大学 产品经理
大拿老师:这个简历,连手机号码和照片都没打码,那为什么关键要素求职职位就不写呢? 从上往下看,都没看出自己到底是产品经理的简历,还是电子硬件的简历? 这是一个大问题,当然,更大的问题是实习经历的描述是不对的 不要只是去写实习流程,陈平,怎么去开会?怎么去讨论? 面试问的是你的产品功能点,是怎么设计的?也就是要写项目的亮点,有什么功能?这个功能有什么难处?怎么去解决的? 实习流程大家都一样,没什么优势,也没有提问点,没有提问,你就不得分 另外,你要明确你投的是什么职位,如果投的是产品职位,你的项目经历写的全都是跟产品无关的,那你的简历就没用 你的面试官必然是一个资深的产品经理,他不会去问那些计算机类的编程项目 所以这种四不像的简历,在校招是大忌
点赞 评论 收藏
分享
点赞 收藏 评论
分享
牛客网
牛客企业服务