# TypeScript 类型检查和保护
# 类型检查机制
类型检查机制: TypeScript 编译器在做类型检查时,所秉承的一些原则,以及表现出的一些行为。其作用是辅助开发,提高开发效率
# 类型推断
类型推断: 指的是不需要指定变量的类型(包括函数的返回值类型),TypeScript 可以根据某些规则自动地为其推断出一个类型
# 基础类型推断
具有初始化值的变量,或者有默认值的函数参数、函数返回的类型等都可以推断出来。
let a = 1 // 推断为 number
let b = [1] // 推断为 number[]
let c = (x = 1) => x + 1 // 推断参数 x 的类型是 number 或者 undefined , 推断整个函数为 (x?: number) => number
# 最佳通用类型推断
当需要从多个类型中推断出一个类型的时候,TypeScript 会尽可能的推断出一个兼容当前所有类型的通用类型
let d = [1, null]
// 推断为一个最兼容的类型,所以推断为(number | null)[]
// 当关闭"strictNullChecks"配置项时,null是number的子类型,所以推断为number[]
# 上下文类型推断
以上的推断都是从右向左,即根据表达式推断,上下文类型推断是从左向右,通常会发生在事件处理中。
# let 和 const 的类型推断区别
const 定义为一个不可变更的常量,在缺省类型注解的情况下,TypeScript 推断出它的类型直接由赋值字面量的类型决定,也是一种比较合理的设计。
const str = 'this is string' // str 的类型是 'this is string'
const num = 1 // num 的类型是 1
const bool = true // bool 的类型是 true
let 定义的是可变更的变量,缺省显式类型注解时,变量的类型推断为赋值字面量类型的父类型,称为"Literal widening",也就是字面量类型的拓宽。这种设计也是符合编程预期的,以下面的代码为例,意味着我们可以给 str 和 num 任意值,只要类型是 string 和 number 的子集的变量。
let str = 'any string' // str 的类型是 string
let num = 2 // num 的类型是 number
let bool = false // bool 的类型是 boolean
# 类型守卫
JavaScript 作为一种动态语言,意味着其中的参数、值可以是多态(多种类型)。因此需要区别对待每一种状态,确保对参数、值的操作合法。
在 TypeScript 中,因为受静态类型检测约束,所以在编码阶段必须使用类似的手段确保当前的数据类型支持相应的操作。当然,前提条件是已经显式地注解了类型的多态。
const convertToUpperCase = (strOrArray: string | string[]) => {
if (typeof strOrArray === 'string') {
return strOrArray.toUpperCase()
} else if (Array.isArray(strOrArray)) {
return strOrArray.map((item) => item.toUpperCase())
}
}
上面的代码中,typeof、Array.isArray 条件判断就是类型守卫。类型守卫的作用在于触发类型缩小。实际上,它还可以用来区分类型集合中的不同成员,类型集合一般包括联合类型和枚举类型
# 联合类型的类型守卫
使用类型守卫来区分联合类型的不同成员,常用的类型守卫包括switch、字面量恒等、typeof、instanceof、in 和自定义类型守卫 这几种。
一般来说,如果可枚举的值和条件分支越多,那么使用 switch 就会让代码逻辑更简洁、更清晰;反之,则推荐使用字面量恒等进行判断。联合类型的成员如果是类,可以使用 instanceof。
/**
* 字符串恒等
*/
const convert = (c: 'a' | 1) => {
if (c === 'a') {
return c.toUpperCase() // c is 'a'
} else if (c === 1) {
return c.toFixed() // c is 1
}
}
/**
* switch
*/
const feat = (c: { name: 'tom' } | { name: 'bob' }) => {
switch (c.name) {
case 'tom':
return c.name
case 'bob':
return '123'
}
}
通过类型谓词 is,可以封装自定义类型守卫。
interface Dog {
wang: string
}
interface Cat {
miao: string
}
function isDog(animal: Dog | Cat): animal is Dog {
return 'wang' in animal
}
const getName = (animal: Dog | Cat) => {
if (isDog(animal)) {
return animal.wang
} else {
return animal.miao
}
}
# 失效的类型守卫
失效的类型守卫指的是某些类型守卫应用在泛型函数中时不能缩小类型,即失效了。例如 in 和 instanceof、类型谓词封装的自定义类型守卫在泛型类型缩小上是有区别的。
interface Dog {
wang: string
}
interface Cat {
miao: string
}
function isDog(animal: Dog | Cat): animal is Dog {
return 'wang' in animal
}
function getName<T extends Dog | Cat>(animal: T) {
/**
if ('wang' in animal) {
return animal.wang // TypeScript 4.3.2 之前使用 in ,类型没有缩小,报错
}
return animal.miao // 从而此处也报错
*/
// instanceof 也可以
if (isDog(animal)) {
return animal.wang
}
// return animal.miao // TypeScript 4.3.2 之前报错
return (animal as Cat).miao
}
# 类型拓宽和类型缩小
# Literal Widening
所有通过 let 或 var 定义的变量、函数的形参、对象的非只读属性,如果满足指定了初始值且未显式添加类型注解的条件,那么他们推断出来的类型就是指定的初始值字面量类型拓宽后的类型,这就是字面量类型拓宽。
const str = 'this is string' // 类型没有拓宽:str 的类型是 'this is string'
let str2 = str // 类型拓宽:str2 的类型是 string
/**注意上下的区别**/
const string: 'this is string' = 'this is string' // 类型没有拓宽:string 的类型是 'this is string'
let string2 = string // 类型没有拓宽:string2 的类型也是 'this is string'
# Type Widening
通过 let、var 定义的变量如果满足未显式声明类型注解且被直接赋予了 null 或 undefined 值,会对 null 和 undefined 的类型进行拓宽,推断出这些变量的类型是 any。
注意,上面没有说函数的形参,形参的类型不会进行拓宽
let x = null // 类型拓宽成 any
let y = undefined // 类型拓宽成 any
/** ------分界线------ **/
const z = null // 不拓宽,类型是 null
/** ------分界线------ **/
let fun = (param = null) => param // 形参不拓宽,类型是 null
let z2 = z // 不拓宽,类型为 null
let x2 = x // 不拓宽,类型为 null
let y2 = y // 不拓宽,类型为 undefined
最后几行代码中出现的变量、形参的类型还是保持 null 或 undefined,没有拓宽成 any,也是符合预期的,这样可以让我们更谨慎的对待这些变量、形参。
# Type Narrowing
在 TypeScript 中,我们可以通过某些操作将变量的类型由一个较为宽泛的集合缩小到相对较小、较明确的集合,这就是“Type Narrowing”。
例如可以使用类型守卫将函数参数的类型从 any 缩小到明确的类型。
let func = (anything: any) => {
if (typeof anything === 'string') {
return anything // 此时 anything 的类型是 string
}
return null
}
# 类型断言
在确定自己比 TS 更准确的知道类型时,可以使用类型断言来绕过 TS 的检查,改造旧代码很有效,但是防止滥用。
可以使用 as 语法做类型断言,也可以使用尖括号 + 类型的格式做类型断言 <Bar>{}
,这两种方法虽然没有任何区别,但是尖括号格式会与 JSX 产生语法冲突,因此更推荐 as 语法。
interface Bar {
bar: number
}
let foo = {} as Bar
foo.bar = 1
// 但是推荐变量声明时就要指定类型
let foo1: Bar = {
bar: 1,
}
类型断言的操作对象必须满足某些约束关系,否则我们将得到一个 ts(2352) 的错误,即从类型“原类型”到类型“目标类型”的转换是错误的,因为这两种类型不能充分重叠(可以简单理解为 as 只能转换父子类型),例如 1 as string
。不过,any 和 unknown 这两个特殊类型属于万金油,因为它们既可以被断言成任何类型,反过来任何类型也都可以被断言成 any 或者 unknown。如果想强行断言不充分重叠的情况,可以先断言为 any 或 unknown,再断言为其他的。例如 1 as any as string
除了可以把特定类型断言成符合约束添加的其他类型外,还可以使用字面量 + as const”
语法结构进行常量断言。
另外还有一种特殊非空断言,即在值(变量、属性)的后边添加 !
断言操作符,它可以用来排除值为 null、undefined 的情况。对于非空断言,应该把它视作和 any 一样危险的选择,所以建议用类型守卫来代替非空断言。
# 推荐阅读:TypeScript 中的类型控制流分析演进(包含类型守卫,类型拓宽和缩小) (opens new window)
# 类型兼容
当一个类型 Y 可以被赋值给另一个类型 X 时,我们就可以说类型 X 兼容类型 Y
X兼容Y:X(目标类型) = Y(源类型)
let s: string = 'a'
s = null // 把编译配置中的strictNullChecks设置成false,字符类型是兼容null类型的(因为null是字符的子类型)
# 接口兼容
成员少的兼容成员多的
interface X {
a: any
b: any
}
interface Y {
a: any
b: any
c: any
}
let x: X = { a: 1, b: 2 }
let y: Y = { a: 1, b: 2, c: 3 }
// 源类型只要具有目标类型的必要属性,就可以进行赋值。接口之间相互兼容,成员少的兼容成员多的。
x = y
// y = x // 不兼容
// x = { a: 1, b: 2, c: 3 } // ts(2322) 报错
虽然成员少的可以兼容成员多的,但是如果直接把跟 Y 结构完全一样的对象字面量赋值给 x,则会提示一个 ts(2322)类型不兼容的错误,这就是对象字面量的 freshness 特性。 也就是说一个对象字面量没有被变量接收时,它将处于一种 freshness 新鲜的状态。这时 TypeScript 会对对象字面量的赋值操作进行严格的类型检测,只有目标变量的类型与对象字面量的类型完全一致时,对象字面量才可以赋值给目标变量,否则会提示类型错误。使用变量接收对象字面量或使用类型断言解除 freshness
# 函数兼容性
type Handler = (a: number, b: number) => void
function test(handler: Handler) {
return handler
}
# 1、参数个数
1.1、固定参数
目标函数的参数个数一定要多于源函数的参数个数
Handler 目标函数,传入 test 的 参数函数 就是源函数
let handler1 = (a: number) => {}
test(handler1) // 传入的函数(源函数)能接收一个参数,且参数是number,是兼容的
let handler2 = (a: number, b: number, c: number) => {}
test(handler2) // 会报错 传入的函数能接收三个参数(参数多了),且参数是number,是不兼容的
1.2、 可选参数和剩余参数
let a1 = (p1: number, p2: number) => {}
let b1 = (p1?: number, p2?: number) => {}
let c1 = (...args: number[]) => {}
(1) 固定参数是可以兼容可选参数和剩余参数的
a1 = b1 // 兼容
a1 = c1 // 兼容
(2) 可选参数是不兼容固定参数和剩余参数的,但是可以通过设置"strictFunctionTypes": false 来消除报错,实现兼容
b1 = a1 //不兼容
b1 = c1 // 不兼容
(3) 剩余参数可以兼容固定参数和可选参数
c1 = a1 // 兼容
c1 = b1 // 兼容
# 2、参数类型
2.1、 基础类型
// 接上面的test函数
let handler3 = (a: string) => {}
test(handler3) // 类型不兼容
2.2、 接口类型
接口成员多的兼容成员少的,也可以理解把接口展开,参数多的兼容参数少的。对于不兼容的,也可以通过设置"strictFunctionTypes": false 来消除报错,实现兼容
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 // 不兼容
2.3、 函数类型
# 3、返回值类型
目标函数的返回值类型必须与源函数的返回值类型相同,或者是其子类型
let f = () => ({ name: 'Alice' })
let g = () => ({ name: 'A', location: 'beijing' })
f = g // 兼容
g = f // 不兼容
# 4、函数重载
函数重载列表(目标函数)
function overload(a: number, b: number): number
function overload(a: string, b: string): string
函数的具体实现(源函数)
function overload(a: any, b: any): any {}
目标函数的参数要多于源函数的参数才能兼容
function overload(a: any, b: any, c: any): any {} // 具体实现时的参数多于重载列表中匹配到的第一个定义的函数的参数,也就是源函数的参数多于目标函数的参数,不兼容
返回值类型不兼容
function overload(a: any, b: any) {} // 去掉了返回值的any,不兼容
# 枚举类型兼容性
enum Fruit {
Apple,
Banana,
}
enum Color {
Red,
Yello,
}
# 枚举类型和数字类型是完全兼容的
let fruit: Fruit.Apple = 4
let no: number = Fruit.Apple
# 枚举类型之间是完全不兼容的
let color: Color.Red = Fruit.Apple // 不兼容
# 类的兼容性
和接口比较相似,只比较结构,需要注意,在比较两个类是否兼容时,静态成员和构造函数是不参与比较的,如果两个类具有相同的实例成员,那么他们的实例就相互兼容
class A {
constructor(p: number, q: number) {}
id: number = 1
}
class B {
static s = 1
constructor(p: number) {}
id: number = 2
}
let aa = new A(1, 2)
let bb = new B(1)
// 两个实例完全兼容,静态成员和构造函数是不比较的
aa = bb
bb = aa
# 私有属性
类中存在私有属性情况有两种,如果其中一个类有私有属性,另一个没有。没有的可以兼容有的,如果两个类都有,那两个类都不兼容。
如果一个类中有私有属性,另一个类继承了这个类,那么这两个类就是兼容的。
class A {
constructor(p: number, q: number) {}
id: number = 1
private name: string = '' // 只在A类中加这个私有属性,aa不兼容bb,但是bb兼容aa,如果A、B两个类中都加了私有属性,那么都不兼容
}
class B {
static s = 1
constructor(p: number) {}
id: number = 2
}
let aa = new A(1, 2)
let bb = new B(1)
aa = bb // 不兼容
bb = aa // 兼容
// A中有私有属性,C继承A后,aa和cc是相互兼容的
class C extends A {}
let cc = new C(1, 2)
// 两个类的实例是兼容的
aa = cc
cc = aa
# 泛型兼容
# 泛型接口
泛型接口为空时,泛型指定不同的类型,也是兼容的。
interface Empty<T> {}
let obj1: Empty<number> = {}
let obj2: Empty<string> = {}
// 兼容
obj1 = obj2
obj2 = obj1
如果泛型接口中有一个接口成员时,类型不同就不兼容了
interface Empty<T> {
value: T
}
let obj1: Empty<number> = {}
let obj2: Empty<string> = {}
// 报错,都不兼容
obj1 = obj2
obj2 = obj1
# 泛型函数
两个泛型函数如果定义相同,没有指定类型参数的话也是相互兼容的
let log1 = <T>(x: T): T => {
return x
}
let log2 = <U>(y: U): U => {
return y
}
log1 = log2
log2 = log1
# 兼容性总结
- 结构之间兼容:成员少的兼容成员多的
- 函数之间兼容:参数多的兼容参数少的
# 类型保护机制
指的是 TypeScript 能够在特定的区块(类型保护区块
)中保证变量属于某种特定的类型。可以在此区块中放心地引用此类型的属性,或者调用此类型的方法。
// 前置代码,之后的代码在此基础运行
enum Type {
Strong,
Week,
}
class Java {
helloJava() {
console.log('hello Java')
}
java: any
}
class JavaScript {
helloJavaScript() {
console.log('hello JavaScript')
}
javaScript: any
}
实现 getLanguage 方法直接用 lang.helloJava 是不是存在,作为判断是会报错的
function getLanguage(type: Type, x: string | number) {
let lang = type === Type.Strong ? new Java() : new JavaScript()
// 报错:如果想根据lang实例的类型,直接用lang.helloJava是不是存在来作为判断是会报错的,因为现在lang是Java和JavaScript这两种类型的联合类型
if (lang.helloJava) {
lang.helloJava()
} else {
lang.helloJavaScript()
}
return lang
}
利用之前的知识可以使用类型断言解决
function getLanguage(type: Type, x: string | number) {
let lang = type === Type.Strong ? new Java() : new JavaScript()
// 这里就需要用类型断言来告诉TS当前lang实例要是什么类型的
if ((lang as Java).helloJava) {
;(lang as Java).helloJava()
} else {
;(lang as JavaScript).helloJavaScript()
}
return lang
}
类型保护第一种方法,instanceof
function getLanguage(type: Type, x: string | number) {
let lang = type === Type.Strong ? new Java() : new JavaScript()
// instanceof 可以判断实例是属于哪个类,这样TS就能判断了。
if (lang instanceof Java) {
lang.helloJava()
} else {
lang.helloJavaScript()
}
return lang
}
类型保护第二种方法, in 可以判断某个属性是不是属于某个对象
function getLanguage(type: Type, x: string | number) {
let lang = type === Type.Strong ? new Java() : new JavaScript()
// in 可以判断某个属性是不是属于某个对象 如上helloJava和java都能判断出来
if ('java' in lang) {
lang.helloJava()
} else {
lang.helloJavaScript()
}
return lang
}
类型保护第三种方法, typeof 类型保护,可以帮助我们判断基本类型
function getLanguage(type: Type, x: string | number) {
let lang = type === Type.Strong ? new Java() : new JavaScript()
// x也是联合类型,typeof类型保护,可以判断出基本类型。
if (typeof x === 'string') {
x.length
} else {
x.toFixed(2)
}
return lang
}
类型保护第四种方法,通过创建一个类型保护函数来判断对象的类型
类型保护函数的返回值有点不同,用到了 is ,叫做类型谓词
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
}
# 总结
不同的判断方法有不同的使用场景:
- typeof:判断一个变量的类型(多用于基本类型)
- instanceof:判断一个实例是否属于某个类
- in:判断一个属性是否属于某个对象
- 类型保护函数:某些判断可能不是一条语句能够搞定的,需要更多复杂的逻辑,适合封装到一个函数内。