深入讲解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<T> = {
  [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> = T extends 'x' ? 1: 2;
type A3 = P<'x' | 'y'>  // A3 = 1 | 2

为什么A2A3的值不一样:

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

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

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

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

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

type NoDistribute<T> = T & {};

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

type A1 = Wrapped<number | boolean>; // "N"
type A2 = Wrapped<true | false>; // "Y"
type A3 = Wrapped<true | false | 599>; // "N"

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

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

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

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

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

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

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

// 如果判断条件是 never,还是仅在作为泛型参数时才跳过判断
type Special3 = never extends never ? 1 : 2; // 1
type Special4<T> = T extends never ? 1 : 2;
type Special4Res = Special4<never>; // 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<Animal>
let Eg4: Array<Dog>
// 兼容,可以赋值
Eg3 = Eg4

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

协变与逆变(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<T> = (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> = 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> = 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

Partial<T>T的所有类型变为可选的。

// 核心实现就是通过映射类型遍历T上所有的属性,
// 然后将每个属性设置为可选属性
···
type Partial<T> = {
  [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<T, K extends keyof T>=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<T> = {
  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<T, K extends keyof T> = {
  [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<K extends keyof any, T> = {
  [P in K]: T
}

其实,Record<string, unknown>Record<string, any>是日常使用较多的形式,通常我们使用这两者来代替 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的类型组成的联合类型。

/*
 * 遍历T中的所有子类型,如果该子类型约束于U(存在于U,兼容于U)
 * 则返回never类型,否则返回该子类型
 */
  type Exclude<T, U> = 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的所有交集。

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

Omit原理分析

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

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

Parameters

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

// 具体实现
type Parameters<T extends (...args: any) = >any>  =  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<any> > = T extends Arrary<infer P> ? P : never;

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

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

ReturnType 获取函数的返回值类型

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

ConstructorParameters

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

/*
 * 核心实现还是利用infer进行推导构造函数的参数类型
 */
type ConstructorParameters<T extends abstract new (...args: any) => 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<typeof People>
  • 首先约束条件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<T extends abstract new (...args: any)=> any> = T extends abstract new (...args: any) => infer R ? R :any;
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容