TypeScript重学札记 (二)

2,925 阅读14分钟

类型检查机制

TypeScript编译器在做类型检查时,所秉承的一些原则,以及表现出的一些行为。

作用:辅助开发,提高开发效率

  • 类型推断
  • 类型兼容性
  • 类型保护

类型推断

interface AmeFoo {
    name: string
}
let ameFoo = {} as AmeFoo
ameFoo.name = 'coboy'

推荐在声明的时候就指定类型

let ameFoo1: AmeFoo = { name: 'coboy' }

类型断言可以增加我们代码的灵活性,在改造一些旧代码的时候非常有效,但使用类型断言,要注意避免滥用,要对上下文的环境要有充足的预判,没有任何根据的类型断言会带来安全的隐患,总之TS的类型推断可以为我们提供重要的辅助信息,应该善加利用。

类型兼容性

当一个类型Y可以被赋值给另一个类型X时,我们就可以说类型X兼容类型Y

X兼容Y:X(目标类型)= Y(源类型)

之所以我们要讨论类型兼容性问题,是因为TS允许我们把一些类型不同的变量相互赋值。类型兼容性的例子广泛存在于接口、函数和类中。

接口兼容性

interface AmeX {
    name: any;
    age: any;
}
interface AmeY {
    name: any;
    age: any;
    height: any;
}
let ameX: AmeX = { name: 'coboy', age: 25 }
let ameY: AmeY = { name: 'cobyte', age: 25, height: 180 }
ameX = ameY
ameY = ameX // Property 'height' is missing in type 'AmeX' but required in type 'AmeY'

这里再次体现了TS的检测原则,也就是鸭式变形法(一只鸟走起来像鸭子,游起来像鸭子,叫起来像鸭子,就可以被认为是鸭子)

源类型必须具备目标类型的必要属性,就可以进行赋值(成员少的会兼容成员多的)

函数兼容性

要判断两个函数是否兼容通常发生在相互赋值的情况下,也就是函数作为参数的情况下

type Handler = (x: number, y: number) => void
function add(handler: Handler) {
    return handler
}
参数个数
let handler1 = (x: number) => {}
add(handler1)
let handler2 = (x: number, y: number, z: number) => {}
add(handler2) // Argument of type '(x: number, y: number, z: number) => void' is not assignable to parameter of type 'Handler'

可选参数和剩余参数

let fun1 = (p1: number, p2: number) => {}
let fun2 = (p1?: number, p2?: number) => {}
let fun3 = (...args: number[]) => {}

固定参数可以兼容可选参数和剩余参数

fun1 = fun2 
fun1 = fun3 

可选参数是不兼容固定参数和剩余参数

fun2 = fun3 // error
fun2 = fun1 // error
// 可以将strictFunctionTypes设置为false

剩余参数可以兼容固定参数和可选参数

fun3 = fun1
fun3 = fun2
参数类型
let handler3 = (a: string) => {}
add(handler3) // error
interface Point3D {
    x: number;
    y: number;
    z: number;
}
interface Point2D {
    x: number;
    y: number;
}
let p3d = (point: Point3D) => {}
let p2d = (point: Point2D) => {}
p3d = p2d
p2d = p3d // error
// 可以将strictFunctionTypes设置为false

这种函数的参数之间可以赋值的情况,叫做函数参数的双向协变,这种情况允许我们把一个精确的类型赋值给一个不那么精确的类型,这样做我们就不需要把一个不精确的类型断言成一个精确的类型。

返回值类型

ts要求我们目标的返回值类型必须与源函数的返回值类型相同,或者为其子类型

let fun4 = () => ({name: 'coboy'})
let fun5 = () => ({name: 'cobyte', age: 18})
fun4 = fun5
fun5 = fun4 // error
function overload(x: number, y: number): number
function overload(x: string, y: string): string
function overload(x: any, y: any): any {}

函数重载分为两部分,第一部分就是函数重载的列表,第二部分就是函数的具体实现,这里列表中的函数就是目标函数,而具体的实现函数就是源函数。程序在运行的时候,编译器会查找重载列表,然后使用第一个匹配的定义来执行下面的函数,所以在重载列表中,目标函数的参数要多于源函数的参数,而且返回值类型也要符合相应的要求。

枚举兼容性
enum Fruit { Apple, Banana }
enum Color { Red, Yellow }
let fruit: Fruit.Apple = 3
let no: number = Fruit.Apple

枚举类型和数字类型是可以完全相互兼容的

枚举之间是完全不兼容的

类的兼容性
class AmeByte1 {
    constructor(x: number, y: number) {}
    id: number = 1
}
class AmeByte2 {
    static x = 1
    constructor(p: number) {}
    id: number = 2
}
let amebyte1 = new AmeByte1(1, 2)
let amebyte2 = new AmeByte2(1)
amebyte1 = amebyte2
amebyte2 = amebyte1

类的兼容性和接口的比较相似,他们也只是比较结构。注意:在比较两个类是否兼容的时候,静态成员和构造函数是不参与比较的,如果两个类具有相同的实例成员,那么他们的实例就可以互相兼容。

如果两个类含有私有成员,那么这两个类就不兼容了,这个时候只有父类和子类之间是互相兼容的。

class AmeByte3 {
    constructor(x: number, y: number) {}
    id: number = 1
    private name: string = ''
}
class AmeByte4 {
    static x = 1
    constructor(p: number) {}
    id: number = 2
    private name: string = ''
}
let amebyte3 = new AmeByte3(1, 2)
let amebyte4 = new AmeByte4(1)
amebyte3 = amebyte4 // error
amebyte4 = amebyte3 // error

class ChildAmeByte3 extends AmeByte3 {}
let childAmeByte3 = new ChildAmeByte3(1, 2)
amebyte3 = childAmeByte3
childAmeByte3 = amebyte3
泛型兼容性
interface Empty1<T> {}
let obj1: Empty1<number> = {}
let obj2: Empty1<string> = {}
obj1 = obj2

interface Empty2<T> {
    value: T
}
let obj3: Empty2<number> = {} // error
let obj4: Empty2<string> = {} // error
obj3 = obj4 // error

只有类型参数T被接口成员使用的时候,才会有影响泛型的兼容性

泛型函数
let ameT1 = <T>(x: T): T => {
    console.log('x')
    return x
}
let ameT2 = <U>(y: U): U => {
    console.log('y')
    return y
}
ameT1 = ameT2

如果两个泛型函数的定义相同但没有指定类型参数,那么他们之间也是可以互相兼容的。

总结:

  • 结构之间的兼容:成员少的兼容成员多的
  • 函数之间的兼容:参数多的兼容参数少的

类型保护

TypeScript能够在特定的区块中保证变量属于某种确定的类型,可以在此区块中放心地引用此类型的属性,或者调用此类型的方法。

enum Type { Strong, Week }

class Java {
    helloJava() {
        console.log('hello Java')
    }
    java: any
}

class JavaScript {
    helloJavaScript() {
        console.log('hello JavaScript')
    }
    javascript: any
}

function isJava(lang: Java | JavaScript): lang is Java {
    return (lang as Java).helloJava !== undefined
}
类型断言
function getLanguage(type: Type, x: string | number) {
    let lang = type === Type.Strong ? new Java() : new JavaScript()
    if((lang as Java).helloJava) {
        (lang as Java).helloJava()
    } else {
        (lang as JavaScript).helloJavaScript()
    }
    return lang
}
getLanguage(Type.Strong)
instanceof
function getLanguage(type: Type, x: string | number) {
    let lang = type === Type.Strong ? new Java() : new JavaScript()
    if(lang instanceof Java) {
        lang.helloJava()
    } else {
        lang.helloJavaScript()
    }
    return lang
}
getLanguage(Type.Strong)
in
function getLanguage(type: Type, x: string | number) {
    let lang = type === Type.Strong ? new Java() : new JavaScript()
    if('java' in lang) {
        lang.helloJava()
    } else {
        lang.helloJavaScript()
    }
    return lang
}
getLanguage(Type.Strong)
typeof
function getLanguage(type: Type, x: string | number) {
    let lang = type === Type.Strong ? new Java() : new JavaScript()
    if(typeof x === 'string') {
        x.length
    } else {
        x.toFixed(2)
    }
    return lang
}
getLanguage(Type.Strong)
类型谓词
function isJava(lang: Java | JavaScript): lang is Java {
    return (lang as Java).helloJava !== undefined
}
function getLanguage(type: Type, x: string | number) {
    let lang = type === Type.Strong ? new Java() : new JavaScript()
    if(isJava(lang)){
        lang.helloJava()
    } else {
        lang.helloJavaScript()
    }
    return lang
}
getLanguage(Type.Strong)

高级类型

交叉类型

所谓交叉类型就是将多个类型合并为一个类型,新的类型具有所有类型的特性,所以交叉类型特别适合对象混入的场景。

interface DogInterface {
    run(): void
}
interface CatInterface {
    jump(): void
}
let pet: DogInterface & CatInterface = {
    run() {},
    jump() {}
}

需要注意的是交叉类型看名称给人的感觉是几个类型的交集,实际上是取所有类型的并集。

联合类型

所谓联合类型就是指声明的类型并不确定,可以为多个类型中的一个。

let ameType: number | string = '1' // 可以等于数字也可以等于字符串
字面量类型

有的时候我们不仅需要限定一个变量的类型,而且要限定变量的取值在某一个特定的范围内。

let b: 'a' | 'b' | 'c'
对象联合类型

如果一个对象是联合类型,那么在类型未确定的情况下,它就只能访问所有类型的共有成员。

class DogImpl implements DogInterface {
    run() {}
    eat() {}
}
class CatImpl implements CatInterface {
    jump() {}
    eat() {}
}
enum Master { Boy, Girl }
function getPet(master: Master) {
    let pet = master === Master.Boy ? new DogImpl() : new CatImpl()
    pet.eat() // 如果一个对象是联合类型,那么在类型未确定的情况下,它就只能访问所有类型的共有成员
    pet.run() // error
    return pet
}

这个时候有趣的事情发生了,从名称上看联合类型给人感觉是取所有类型的并集,而实际情况只能访问所有成员的交集。

可区分的联合类型

本质上是结合了联合类型和字面量类型的一种类型保护方法。本质上是结合了联合类型和字面量类型的一种类型保护方法。它的核心思想是一个类型如果是多个类型的联合类型并且每个类型之间有一个公共的属性,我们就可以凭借这个公共属性创建不同的类型保护区块。

interface Square {
    kind: "square";
    size: number;
}
interface Rectangle {
    kind: "rectangle";
    width: number;
    height: number;
}
type Shape = Square | Rectangle
function area(s: Shape) {
    switch(s.kind) {
        case "square":
            return s.size * s.size
        case "rectangle":
            return s.height * s.width
    }
}

上面的代码如果不去升级是不会有问题的,但如果我们想加一种新的模式,它就会有问题了。

interface Square {
    kind: "square";
    size: number;
}
interface Rectangle {
    kind: "rectangle";
    width: number;
    height: number;
}
interface Circle {
    kind: 'circle',
    r: number
}
type Shape = Square | Rectangle | Circle
function area(s: Shape) {
    switch(s.kind) {
        case "square":
            return s.size * s.size
        case "rectangle":
            return s.height * s.width
        case "circle":
            return Math.PI * s.r ** 2
        default: 
            return ((e: never) => {throw new Error(e)})(s) 
    }
}
((e: never) => {throw new Error(e)})(s)

这段函数的作用是:检测s是不是never类型,如果s是never类型,就说明上面的分支都被覆盖了,这个分支永远不会执行,那么如果s不是never类型,就说明以前的分支有遗漏。

索引类型

我们有时候会遇到这样的一种场景,就是从对象中获取一些属性的值然后建立一个集合。

let obj = {
    a: 1,
    b: 2,
    c: 3
}
function getValues(obj: any, keys: string[]) {
    return keys.map(key => obj[key])
}

console.log(getValues(obj, ['a', 'b']))

console.log(getValues(obj, ['e', 'f'])) // 随意指定不存在的属性,但不报错

随意指定没有的属性,但ts编译器并没有报错,所以这个时候我们需要对类型进行约束,这个时候我们就需要用到了索引类型。

下面我们要先了解一下索引类型的几个必要概念

keyof T

表示类型T的所有公共属性的字面量的联合类型

interface Obj {
    a: number,
    b: string
}
type key = keyof Obj
T[k]

表示对象T的属性k所代表的类型

let value: Obj['a']
泛型约束:T extends U

表示泛型变量可以通过继承某个类型获得某些属性

改造上面的代码

let obj = {
    a: 1,
    b: 2,
    c: 3
}
function getValues<T,K extends keyof T>(obj: T, keys: K[]): T[K][] {
    return keys.map(key => obj[key])
}
console.log(getValues(obj, ['a', 'b']))

console.log(getValues(obj, ['e', 'f'])) // error 这个时候就报错了

可以看到索引类型可以实现对对象属性的查询和访问,然后配合泛型约束,就能告诉我们建立对象、对象属性以及属性值之间的约束关系。

映射类型

通过映射类型可以把一个旧的类型生成一个新的类型

将一个接口的所有属性映射为只读:

interface objMapping {
    a: string;
    b: number;
    c: boolean;
}

type ReadonlyObj = Readonly<objMapping>

ReadonlyObj与objMapping成员完全相同,区别是ReadonlyObj中的成员属性均为只读

将一个接口的所有属性变成可选的Partial映射类型

type PartialObj = Partial<objMapping>

可以抽取对象子集的Pick映射类型:

type PickObj = Pick<objMapping, 'a'|'b'>
Readonly的实现原理:

从源码可以看出Readonly是一个可索引类型的泛型接口

/**
 * Make all properties in T readonly
 */
type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};
  1. 索引签名为P in keyof T :

    其中keyof T就是一个一个索引类型的查询操作符,表示类型T所有属性的联合类型

  2. P in :

    相当于执行了一个for in操作,会把变量P依次绑定到T的所有属性上

  3. 索引签名的返回值就是一个索引访问操作符 : T[P] 这里代表属性P所指定的类型

  4. 最后再加上Readonly就把所有的属性变成了只读,这就是Readonly的实现原理

Partial的实现原理:
/**
 * Make all properties in T optional
 */
type Partial<T> = {
    [P in keyof T]?: T[P];
};

可选和只读映射类型的实现几乎一样,知识属性变为可选

Pick映射类型的实现原理:
/**
 * From T, pick a set of properties whose keys are in the union K
 */
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

Pick映射类型有两个参数:

第一个参数T,表示要抽取的目标对象

第二个参数K,具有一个约束:K一定要来自T所有属性字面量的联合类型,

即映射得到的新类型的属性一定要从K中选取

以上三种映射类型官方称为同态类型,意思是只作用于obj属性而不会引入新的属性

非同态类型

Record 是非同态类型

type RecordObj = Record<'m' | 'n', objMapping>

第一个参数是预定义的新属性,比如m,n

第二个参数就是已知类型

映射出的新类型所具有的属性由Record的第一个属性指定,而这些属性类型为第二个参数指定的已知类型,这种类型就是一个非同态的类型

Record映射类型源码:

/**
 * Construct a type with a set of properties K of type T
 */
type Record<K extends keyof any, T> = {
    [P in K]: T;
};

非同态类型本质上会创建新的属性

Readonly, Partial和 Pick是同态的,但 Record不是。 因为 Record并不需要输入类型来拷贝属性,所以它不属于同态,非同态类型本质上会创建新的属性

 映射类型本质上是一种预先定义的泛型接口,通常还会结合索引类型,获取对象的属性和属性值,从而像一个对象映射成我们想要的结构。

条件类型

条件类型是一种由条件表达式决定的类型

T extends U ? X : Y

意思是如果类型T可以赋值给类型U,那么结果类型就是X类型,否则就是Y类型,条件类型使类型具有了不唯一性,同样增加了语言的灵活性

分步式条件类型

当类型T为联合类型时:

T为类型A和类型B的联合类型,结果类型会变成多个条件类型的联合类型

(A | B) extends U ? X : Y

可以将A和B进行拆解:

(A extends U ? X : Y) | (B extends U ? X : Y)

这时定义的变量就会被推断为联合类型

type T3 = TypeName<string | string[]>

可以看到,传入string | string[]联合类型,被推断为string|object的联合类型

利用上边这个特性可以实现对类型的过滤

type Diff<T, U> = T extends U ? never : T

如果T可以被赋值给U,结果类型为never类型,否则为T类型

type T4 = Diff<'a' | 'b' | 'c', 'a' | 'e'>

T4的类型被推断为b和c的联合类型,过滤掉了第二个参数中已经含有类型a

// Diff<'a', 'a' | 'e'> | Diff<'b', 'a' | 'e'> | Diff<'c', 'a' | 'e'>
// never | "b" | "c"
// "b" | "c"
  1. 先判断a是否可以被赋值给这个字面量联合类型'a' | 'e',答案是可以的,所以返回never
  2. 继续,因为b不可以被赋值给字面量联合类型'a' | 'e',所以返回b
  3. 继续,c不可以被赋值给'a' | 'e',所以返回c
  4. 最后,never和b,c的联合类型为'b' | 'c'

Diff类型作用:

可以从类型T中过滤掉可以被赋值给类型U的类型

也可以实现从类型T中移除不需要的类型,如undefined和null

定义一个NotNull,从T中过滤掉undefined和null

type NotNull<T> = Diff<T, undefined | null>
type T5 = NotNull<string | number | undefined | null>

过滤掉undefined和null,T5的类型就变成了string和number

上述的Diff和NotNull类型,是已经在TS内置的类库中被实现的内置类型

  • Diff的内置类型叫做Exclude<T, U>
  • NotNull的内置类型叫做NonNullable

此外,官方还预置了一些条件类型,如:Extract 和 Exclude

  • Extract和Exclude相反
  • Exclude作用是从类型T中过滤掉可以赋值给类型U的类型
  • Extract作用是可以从类型T中抽取出可以赋值给U的类型
type T6 = Extract<'a' | 'b' | 'c', 'a' | 'e'>
type T7 = Exclude<'a' | 'b' | 'c', 'a' | 'e'>

T6抽取了在类型U中存在的类型a

T7抽取了在类型U中不存在的类型b和c

源码:

/**
 * Exclude from T those types that are assignable to U
 */
type Exclude<T, U> = T extends U ? never : T;

/**
 * Extract from T those types that are assignable to U
 */
type Extract<T, U> = T extends U ? T : never;

ReturnType 源码:

/**
 * Obtain the return type of a function type
 */
type ReturnType<T extends (...args: any) => any> 
	= T extends (...args: any) => infer R ? R : any;

T extends (...args: any) => any: ReturnType要求参数T可以赋值给一个函数,这个函数有任意的参数,返回值类型也是任意的 由于函数返回值类型不确定,这里使用了infer关键字,表示待推断,延迟推断,需要根据实际的情况确定

infer R ? R : any: 如果实际类型是R,那么结果类型就是R,否则返回值类型就是any