深入讲解Ts中高级类型工具

写在最前:本文转自掘金

一、 前置内容

[key: string]索引签名类型

索引签名类型主要指的是在接口或类型别名中,通过以下语法来快速声明一个键值类型一致的类型结构:

interface Eg1{
  [key: string]: string;
}

keyof 索引查询

对应任何类型Tkeyof T 的结果为该类型上所有共有属性key的联合:

interface Eg1{
  name: string;
  readonly age: number;
}
// T1的类型是 'name' | 'age'
type T1 = keyof Eg1

class Eg2 {
  private name: string;
  public readonly age: number;
  protected home: string;
}

// T2实则约束为 'age'
type T2 = keyof Eg2

T[K] 索引访问

interface Eg1{
  name: string;
  readonly age: number;
}

type V1 = Eg1['name']  // string
type V2 = Eg1['name' | 'age']  // string | number
type V2 = Eg1['name' | 'age222']  // any
type V3 = Eg1[keyof Eg1]  // string | number

T[keyof T] 的方式,可以获取到T所有key的类型组成的联合类型;注意:如果[]中的key有不存在T中的,则是any;因为ts也不知道该key最终是什么类型,且不会报错;

in 映射类型

而映射类型,就是使用了 PropertyKeys 联合类型的泛型,其中 PropertyKeys 多是通过 keyof 创建,然后循环遍历键名创建一个类型:

type Clone = {
  [K in keyof T]: T[K];
};

&交叉类型注意点

交叉类型取的多个类型的并集,如果相同key但类型不同,则该keynever

interface Eg1{
  name: string;
  age: number;
}
interface Eg2{
  color: string;
  age: string;
}
 type T = Eg1 & Eg2  // T的类型为{ name: string;age: never; color: string },注意,age因为两者接口内的类型不一致所有事never
// 可通过如下实例验证
const val: T = {
  name: ' ',
  color: ' ' ,
  age: (function a(){ throw Error() })(),
}

extends 关键字特性(重点)

特性一,用于接口,表示继承

interface T1{
  name: string;
}
interface T2{
  sex: number;
}

// T3 = {name: string; sex: number; age:number;}
interface T3 extends T1,T2{
  age: number,
}

注意,接口支持多重继承,语法为逗号隔开。如果是type实现继承,则可以使用交叉类型 type A = B& C & D

特性二,表示条件类型,可用于条件判断
表示条件判断,如果前面的条件满足,则返回问号后的第一个参数,否则第二个。类似于js 的三元运算。

A extends B,A为子类型,B为父类型 ,在接口中,属性约束宽泛为父类型,子类型应该继承父类型所有属性并加以更多属性约束。 在联合类型中,类型约束越宽泛为父类型,子类型应继承父类型基础上,缩减类型。

type A1 = 'x' extends 'x' ? 1: 2;  //  A1 = 1

type A2 = 'x' | 'y' extends 'x' ? 1: 2;  // A2 = 2

type P = T extends 'x' ? 1: 2;
type A3 = P<'x' | 'y'>  // A3 = 1 | 2

为什么A2A3的值不一样:

  • 如果用于简单的条件判断,则是之间判断前面的类型是否可分配给后面的类型
  • extends前面的类型是泛型,且泛型传入的是联合类型时,则会依次判断该联合类型的所有子类型是否可分配给extends后面的类型(是一个分发的过程)。

总结,就是extends 前面的参数为联合类型时则会分解(依次遍历所有的子类型进行条件判断)联合类型进行判断,然后最终结果组成新的联合类型。
如果再严谨一些,其实我们就得到了官方的解释:对于属于裸类型参数的检查类型,条件类型会在实例化时期自动分发到联合类型上。如果不想被分发,可以通过简单的元组类型包裹一下,就非裸类型参数了:

type P = [T] extends ['x'] ? 1 : 2;

type A4 = p<'x'|'y'>  // 2

我们除了可以使用数组元组包裹,还可以:

type NoDistribute = T & {};

type Wrapped = NoDistribute extends boolean ? "Y" : "N";

type A1 = Wrapped; // "N"
type A2 = Wrapped; // "Y"
type A3 = Wrapped; // "N"

这里有两个需要单独提出来的特殊情况,anyneverany作为参数,判断条件非any情况下会返回判断结果的联合类型;

// 直接使用,返回联合类型
type Tmp1 = any extends string ? 1 : 2;  // 1 | 2

type Tmp2 = T extends string ? 1 : 2;
// 通过泛型参数传入,同样返回联合类型
type Tmp2Res = Tmp2; // 1 | 2

// 如果判断条件是 any,那么仍然会进行判断
type Special1 = any extends any ? 1 : 2; // 1
type Special2 = T extends any ? 1 : 2;
type Special2Res = Special2; // 1

never作为泛型参数是会返回never

// 直接使用,仍然会进行判断
type Tmp3 = never extends string ? 1 : 2; // 1

type Tmp4 = T extends string ? 1 : 2;
// 通过泛型参数传入,会跳过判断
type Tmp4Res = Tmp4; // never

// 如果判断条件是 never,还是仅在作为泛型参数时才跳过判断
type Special3 = never extends never ? 1 : 2; // 1
type Special4 = T extends never ? 1 : 2;
type Special4Res = Special4; // never

类型兼容性

集合论中,如果一个集合的所有元素在集合B中都存在,则A是B的子集;
类型系统中,如果一个类型的属性更具体,则该类型是子类型。(因为属性更少则说明该类型约束更宽泛,是父类型)

因此,我们得到基本结论:子类型比父类型更加具体,父类型比子类型更宽泛。下面我们也将基于类型的可赋值性、协变、逆变、双向协变等进一步讲解。

可赋值性 子类型可赋值给父类型,反之不行

interface Animal {
  name: string;
}

interface Dog extends Animal {
  break(): void;
}

let a: Animal;
let b: Dog;

a = b // 子类可以赋值给更加宽泛的父类型
b = a // 反过来不行

可赋值性在联合类型中的特性

type A = 1 | 2 | 3
type B = 2 | 3
let a: A;
let b: B;

a = b // 可以赋值
b = a // 不可以赋值

是不是A的类型更多,A就是子类型呢?恰恰相反,A此处类型更多但表达的类型越宽泛,所有A是父类型,B是子类型。因此父类型不能给子类型赋值。

协变

interface Animal {
  name: string;
}
interface Dog extends Animal {
  break(): void;
}

let Eg1: Animal;
let Eg2: Dog;
// 兼容,可以赋值
Eg1 = Eg2

let Eg3: Array
let Eg4: Array
// 兼容,可以赋值
Eg3 = Eg4

通过Eg3Eg4来看,在AnimalDog在变成数组后,Array依旧可以赋值给Array,因此对于type MakeArray = Array来说就是协变。
引用维基百科中的定义:

协变与逆变(Convariance and contravariance)是在计算机科学中,描述具有父/子型别关系的多个型别通过型别构造器、构造出的多个复杂型别之间是否有父/子型别关系的用语。

简单说,具有父子关系的多个类型,在通过某种构造器构造成的新的类型,如果还具有父子关系则是协变,而关系逆转了(子转父,父转子)就是逆变。
这种“型变”分为两种,一种是子类型可以赋值给父类型,叫做协变,一种是父类型可以赋值给子类型,叫做逆变。

逆变

interface Animal {
  name: string;
}

interface Dog extends Animal {
  break(): void;
}

type AnimalFn = (arg:Animal) => void
type DogFn = (arg: Dog) => void

let Eg1: AnimalFn;
let Eg2: DogFn;

Eg1 = Eg2; // 不可赋值
Eg2 = Eg1; //可赋值

理论上,Animal = Dog是类型安全的,那么AnimalFn = DogFn也应该类型安全猜对,为什么ts认为不安全呢?看下面例子:

let animal: AnimalFn = (arg: Animal) => {}
let dog: DogFn = (arg: Dog)=>{ arg.break() }

// 假设类型安全可以赋值
animal = dog;
// 那么animal 在调用时约束的参数缺少dog所需要的参数,此时会导致错误
// animal = (arg)=>{arg.break()}
animal({name: 'cat'});

从这个例子看到,如果dog函数赋值给animal函数,那么animal函数在调用时,约束的参数是Animal,但animal实际为dog的调用,传入参数无break()方法,此时就会出现错误。
因此,AnimalDog在进行type Fn = (arg: T) => void 构造器构造后,父子关系就逆转了,此时称为逆变。

双向协变
ts在函数参数的比较中实际上默认采取的策略就是双向协变:只有当源函数参数能够赋值给目标函数或者反过来时才能赋值成功。

这是不稳定的,因为调用者可能传入了一个具有更精确类型信息的函数,但是调用这个传入的函数的时候却使用了不是那么精确的类型信息(典型的就是上述的逆变)。但是实际上,这极少会发生错误,并且能够实现很多JavaScript里常见模式:

// lib.dom.d.ts 中EventListener 的接口定义
interface EventListener{
  (evt: Event): void;
}
//  简化后的Event
interface Event {
  readonly target: EventTarget | null;
  preventDefault(): void;
}
// 简化合并后的MouseEvent
interface MouseEvent extends Event{
  readonly X: number;
  readonly Y: number;
}
// 简化后的window接口
interface window{
  // 简化后的addEventListener
  addEventListerner(type: string,listener: EventListener)
}

// 日常使用
window.addEventListener('click', (e: Event)=> {})
window.addEventListener('mouseover', (e:MouseEvent) => {})

可以看到windowlistener函数要去参数必须是Event,但是日常使用时更多时候传入的是Event子类型。但这里可以正常使用,正式其默认行为是双向协变的原因。可以通过tsconfig.js中修改strictFunctionType属性来严格控制协变和逆变。

重点infer关键词的功能暂时先不做详细说明,主要是用于extends的条件类型中让ts自己推断类型,具体的可以查阅官网。但关于infer的一些容易让人忽略的重要特性,必须提及一下:

infer推导的名称相同并且都处于逆变的位置,则推导的结果将会是交叉类型

type Bar = T extends { a: (x: infer U) => void; b: (x: infer U) => void; } ? U : boolean;

type T1 = Bar<{ a: (x: string) => void; b: (x: string) => void }>;  // string
type T2 = Bar<{ a: (x: string) => void; b: (x: number) => void }>;  // never

let Eg1: { a: (x: string | number) => void; b: (x: number | string) => void }  // 父类  
let Eg2: { a: (x: string) => void; b: (x: number) => void } // 子类
// 允许父类向子类赋值,为逆变,推到结果为交叉类型 never

infer推导的名称相同并且都处于协变的位置,则推导的结果将会是联合类型

type Foo = T extends { a: infer U; b: infer U } ? U : never;

type T3 = Foo<{ a: string; b: string }>;    // string
type T4 = Foo<{ a: number; b: string }>;    // string|number

let Eg3: { a: number | string; b: number | string }  // 父类
let Eg4: { a: number; b: string }  // 子类
// 允许子类向父类赋值,为协变,推到结果为联合类型 number| string

第二部分 ts内置类型工具原理解析

Partial

PartialT的所有类型变为可选的。

// 核心实现就是通过映射类型遍历T上所有的属性,
// 然后将每个属性设置为可选属性
···
type Partial = {
  [P in keyof T]?: T[P];
}
  • [P in keyof T]通过映射类型,遍历T上的所有属性
  • ?:设置属性为可选的
  • T[P]设置类型为原来的类型

扩展一下,将制定的key变成可选类型

/**
 *主要通过K extends keyof T 约束K必须为keyof T的子类
 *keyof T得到的是T的所有key组成的联合类型
 */
type PartialOptional=P{
  [P in K]?:T[P];
}
/**
 *@example 
 *    type Eg1 = {key1?: string; key2?: number}
 */
type Eg1 = PartialOptional<{
  key1:string;
  key2:number;
  key3: '';
}, 'key1'|'key2'>

Readonly

/*
 *主要通过映射遍历所有key,
 *然后给每个key增加一个readonly修饰符
 */
type Readonly = {
  readonly [P in keyof T]: T[P]
}

/*
 * @example
 * type Eg1 = { readonly key1: string; readonly key2: number; }
 */
type Eg1 = Readonly<{
  key1: string;
  key2: number;
}>

Pick

挑选一组属性并组成一个新的类型。

type Pick = {
  [P in K]: T[P]
}

/* @example
 *  type Eg = {key1:string;key3:boolean;}
 *
 */
type Eg = Pick<{
  key1:string;
  key2: number;
  key3: boolean;
}, 'key1'|'key3'>

Record

构造一个typekey 为联合类型中的每个子类型,类型为T

/**
 * @example
 * type Eg = { a: {key1: string}; b: {key2: string} }
 */
type Eg = Record<'a' | 'b', {key1: string}>

Record具体实现

// k作为key,所有的类型仅为三种string|number|symbol ,使用keyof any表示
type Record = {
  [P in K]: T
}

其实,RecordRecord是日常使用较多的形式,通常我们使用这两者来代替 object 类型。

扩展:同态与非同态。

  • PartialReadonlyPick都属于同态,即其实现需要输入类型T来拷贝属性,因此属性修饰符(例如readonly ?:)都会被拷贝。可从下面栗子验证:
// type Eg = {readonly a?: string}
type Eg = Pick<{readonly a?: string}, 'a'>

Eg的结果来看,Pick在拷贝属性时,连带拷贝了readonly?:修饰符。

  • Record 是非同态的,不需要拷贝属性,因此不会拷贝属性修饰符
    根据Pick的实现,P in keyof any并没有拷贝传入类型的属性,而其他几个工具无一例外,都是用了P in keyof T来辅助拷贝传入类型的属性。

Exclude原理解析

Exclude提取存在于T,但不存在与U的类型组成的联合类型。

/*
 * 遍历T中的所有子类型,如果该子类型约束于U(存在于U,兼容于U)
 * 则返回never类型,否则返回该子类型
 */
  type Exclude = T extends U ? never: T
/*
 * @example
 *   type Eg = 'key1'
 */
 type Eg = Exclude<'key1'| 'key2', 'key2'>

注意

  • nerver表示一个不存在的类型
  • nerver与其他类型的联合后,是没有nerver
// type Eg2 = string | number
type Eg2 = string | number | nerver

因此上述Eg其实就等于key1 | never,也就是key1

Extract

Extract 提取联合类型T和联合类型U的所有交集。

/*
 * 遍历T中的所有子类型,如果该子类型约束于U(存在于U,兼容于U)
 * 则返回该子类型,否则返回never
 */
  type Extract = T extends U ?  T: never
/*
 * @example
 *   type Eg = 'key2'
 */
 type Eg = Extract<'key1'| 'key2', 'key2'>

Omit原理分析

Omit从类型T中剔除K中所有属性。

type Omit = Pick>
/*
 * @example
 *  Eg = { key2: number; key3: boolean; }
 */
type Eg = Omit1<{key1:string;key2:number;key3:boolean},'key12'|'key1'|'key13'>
  • 首先我们可以利用Pick提取我们需要的keys组成的类型
  • 也就是Omit = Pick
  • 而我们所需要的属性联合,就是从T的属性联合中排除存在于联合类型K中的
  • 也就是Exclude

Parameters

Parameters 获取函数的参数类型,将每个参数类型放进一个元组中。

// 具体实现
type Parametersany>  =  T extends (...args: infer P) => any ? P :never;

// type Eg = [ arg1: string, arg2: number ]
type Eg = Parameters<(arg1: string, arg2: number) => void>;
  • Parameters 首选约束T必须是一个函数类型,所以(..args: any) => any替换成Function也可以
  • 判断T是否是函数类型,如果是则使用inter P让ts自己去推导函数的参数类型,并将推导的结果存到类型P上,否则就返回never
  • infer关键字作用是让ts自己推导类型,并将推导结果存储在其绑定的类型上。infer P就是将结果存在类型P上供使用
  • infer关键字只能在extends 条件类型上使用,不能在其他地方使用。
  • type Eg = [ arg1: string, arg2: number ] 这是一个元组,但和我们常见的元组不同,可以理解成具名元组。实质上没有什么特殊作用,比如无法通过这个额具名去取值。个人觉得,多了语义化的表达罢了。
  • 定义元组的可选项,只能在最后定义
// 普通元组
type Tuple1 = [ string, number? ];
let a: Tuple1 = [ 'aa', 11 ];
let a2: Tuple1 = [ 'aa' ];

// 具名元组
type Tuple2 = [ name: string, age?: number ];
let b: Tuple2 = [ 'aa', 11 ];
let b2: Tuple2 = [ 'aa' ];

扩展:infer 实现一个推导数组所有元素的类型

type FalttenArray< T extends Arrary > = T extends Arrary ? P : never;

// Eg1 = number | string;
type Eg1 = FalttenArray<[number, string]>

// Eg2 = 1 | 'as'
type Eg2 = FalttenArray<[1 | 'as']>

ReturnType 获取函数的返回值类型

type ReturnType any> = T extends (...args: any) => infer R ? R : any;

ConstructorParameters

ConstructorParameters 可以获取类的构造函数的参数类型,存在一个元组中。

/*
 * 核心实现还是利用infer进行推导构造函数的参数类型
 */
type ConstructorParameters any> = T extends abstract new (...args: infer P) => any ? P : never;

// @example type Eg = [name: string, sex?: number];
class People {
  constructor(public name: string, sex?: number) {}
}
type Eg = ConstructorParameters
  • 首先约束条件T为拥有构造函数的类。注意这里有个abstract修饰符,等下说明。
  • 判断T是满足约束的类时,利用infer P 自动推导出构造函数的参数类型,并最终返回该类型。
  • 其中new (...args: any)为构造签名,new (...args: any) => any为构造函数类型字面量

那么,为什么要对T约束为abstract抽象类呢?看下面栗子:

class MyClass {}       // 定义一个普通类

abstract class MyAbstractClass {}    // 定义一个抽象类

let c1: typeof MyClass = MyClass  // 可以赋值
let c2: typeof MyClass = MyAbstractClass   // 报错,无法将抽象构造函数类型分配给非抽象构造函数类型

let c3: typeof MyAbstractClass = MyClass //可以赋值
let c4: typeof MyAbstractClass = MyAbstractClass  //可以赋值

由此可以看出,可以将抽象类(抽象构造函数)赋值给抽象类或者普通类,反之不行。

那么,为什么使用typeof 类作为类型呢,直接使用类作为类型又有什么区别呢?

// 定义一个类
class People{
  name: string;
  age: number;
  constructor() {}
}
let p1: People = new People   // 可以赋值
let p2: People = People // 不可以赋值 等号后面缺少name, age

let p3: typeof People = People  // 可以赋值
let p4: typeof People = new People()  // 不可以赋值,p4缺少prototype

简单的理解就是

  • typeof 类作为类型,需要赋值为类本身
  • 作为类型,需要赋值为类的实例

最后,只需要对infer的使用换个位置,便可以获取构造函数返回值的类型:

type InstanceType any> = T extends abstract new (...args: any) => infer R ? R :any;

你可能感兴趣的:(深入讲解Ts中高级类型工具)