any 类型

any 主打的就是一个“任意”,而在类型层面,我们也可以这么理解,any 类型 = string + number + boolean + 任意对象类型 + 拥有任意参数类型与任意返回值类型的函数类型 + …,它就是无所不包的,因此,在我们不知道对一个变量提供何种类型时,就可以使用 any 类型来作为临时性的过渡方案。

为什么 any 只是临时过度?

既然 any 类型能表示所有类型,那它还能带给我们精确的类型提示吗?当然不能,实际上使用了 any 类型,就意味着告诉类型检查系统,这个变量我给它开白名单了,你放过它吧,它想干啥就干啥。
这是相当危险的行为,也和我们选择 TypeScript 的原因相悖,因为使用了 any 类型之后,要想代码能够正常运行,你其实又相当于恢复到之前使用 JavaScript。

any 类型的主要意义,其实就是为了表示一个无拘无束的“任意类型”,它能兼容所有类型,也能够被所有类型兼容。这一作用其实也意味着类型世界给你开了一个外挂,无论什么时候,你都可以使用 any 类型跳过类型检查。当然,运行时出了问题就需要你自己负责了。

any 的本质是类型系统中的顶级类型,即 Top Type。

unknown 类型

any 类型 = 万能类型 + 放弃类型检查,其中「万能类型」是我们想要的,能不能只要这个部分,而不要「放弃类型检查」这个危险的行为呢?当然!考虑到 any 类型的危险性,TypeScript 中还提供了一个功能类似的家伙:unknown 类型,用于表示万能类型的同时,保留类型检查。

万能类型

1
2
3
4
5
6
7
function myFunc(param: unknown) {
// ...
}

myFunc({})
myFunc([])
myFunc(true)

看起来都没报错,和 any 类型一样好用。但如果我们尝试在这个函数内使用参数呢?

1
2
3
function myFunc(param: unknown) {
param.forEach((element) => {}) // X “param”的类型为“未知”。
}

在我们尝试使用一个 unknown 类型的变量时,类型检查系统阻止了我们,它要求我们先为这个变量提供一个具体的类型后才能使用。而我们这里调用了 forEach 方法,很明显,我们希望它是一个数组类型!但此时在代码中,param 的类型已经被固定为 unknown,此时我们应该如何修改一个变量的类型?

类型断言

能够修改一个变量的类型——无论是 TS 自己推导的,还是你手动标注的。一旦你为这个变量提供了类型,或者是赋值之后,这个变量的类型就已经固定了,我们无法再对它进行修改。而现在有了类型断言,我们现在可以指着这个变量告诉 TS,这个类型看起来是一个字符串,其实它是一个数字!

上面的例子,如果要将 unknown 类型的变量断言到数组类型,我们可以这么写:

1
2
3
function myFunc(param: unknown) {
;(param as unknown[]).forEach((element) => {})
}

我们将参数类型断言到了一个成员类型为 unknown 的数组类型,而在后面的使用过程中,我们可能需要对数组成员进一步操作:

1
2
3
4
5
function myFunc(param: unknown) {
;(param as unknown[]).forEach((element) => {
element = element + 1
})
}

虽然我们心里希望 element 是数字类型,但是 TS 可猜不到。此时,你可以考虑将 param 的类型一步到位的完善,也可以在后续使用时一步步完善:

1
2
3
4
5
6
7
8
9
10
11
function myFunc(param: unknown) {
;(param as number[]).forEach((element) => {
element = element + 1
})
}

function myFunc(param: unknown) {
;(param as unknown[]).forEach((element) => {
element = (element as number) + 1
})
}

这两种方式使用起来并没有明显的差异,但第二种一步步断言的方式更能体现类型断言的意义:一个变量最开始是未知的类型,但随着后续的一步步使用,我们通过类型断言慢慢地完善这个类型的轮廓,最后完成对初始类型的定义。

小结

any 类型和 unknown 类型都能提供万能类型的作用,但不同之处在于,使用 any 类型后就丧失了类型检查的保护,可以对变量进行任意操作。而使用 unknown 类型时,虽然我们每进行一次操作都需要进行类型断言,断言到当前我们预期的类型,但这却能实现类型信息反向补全的功能,为最终我们的具体类型埋下伏笔。虽然 any 类型的使用过程中也可以通过类型断言保障,但毕竟缺少了类型告警,我们很容易就忽略掉了。

上面我们了解的是初始提供 any / unknown 类型,然后通过类型断言将其断言到预期类型的操作。实际上,还有一个更常见的场景是将一个拥有具体类型的变量断言到 any / unknown 类型:

1
2
3
const str: string = 'jude'

;(str as any).handler().result.prop // ...

为什么我们需要这么做?因为很多时候,你面临的项目中并不会是完全没有类型定义的,这些变量可能最开始也是被维护者精心设计了类型的,但随着项目的不断迭代和维护者的更替,它们才日渐年久失修,导致你在使用这些变量时需要面对大量的类型报错。所以这个时候我们就可以请出类型断言,先将其断言到一个万能类型,然后就重复我们上面学习的,随着一步步调用不断完善类型,然后最后回头补全的过程。

某些时候 TypeScript 的类型分析会显得不那么符合直觉,比如这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
interface IUser {
name: string
job?: IJob
}

interface IJob {
title: string
}

const user: IUser = {
name: 'foo',
job: {
title: 'bar'
}
}

const { name, job = {} } = user

const { title } = job // 类型“{}”上不存在属性“title”。

由于我们在第一次解构赋值时,为 job 提供了一个空对象作为默认值,TypeScript 会认为此时 job 的类型就是一个空对象,所以我们在第二次解构赋值时,就无法从 job 上获得 title 属性了。要解决这个问题,我们可以在第一次解构赋值时将这个空对象断言到预期的类型:

1
2
3
const { name, job = {} as IJob } = user

const { title } = job

原始类型和对象类型标注能够对应到 JavaScript 中的数据类型,any 与 unknown 类型是全新的概念,赋予了我们描述“任意类型”的能力,而 unknown 则是为了解决 any 类型过于无拘无束的特点而诞生的。同时,由于某些变量可能在交到我们手里时就已经获得了自己的初始类型,要使用 any 和 unknown 作为新的变量类型,我们就需要类型断言的帮助,它的作用当然就是将变量类型断言到一个新的类型。通过配合 unknown 类型和类型断言,我们就能够在描述任意类型的同时,确保代码中实际逻辑的安全。

any 类型的万能性也导致我们经常滥用它,比如类型不兼容了就 any 一下,类型不想写了也 any 一下,不确定可能会是啥类型还是 any 一下。此时的 TypeScript 就变成了令人诟病的 AnyScript。为了避免这一情况,我们要记住以下小 tips :

如果是类型不兼容报错导致你使用 any,考虑用类型断言替代,我们下面就会开始介绍类型断言的作用。
如果是类型太复杂导致你不想全部声明而使用 any,考虑将这一处的类型去断言为你需要的最简类型。如你需要调用 foo.bar.baz(),就可以先将 foo 断言为一个具有 bar 方法的类型。
如果你是想表达一个未知类型,更合理的方式是使用 unknown。

unknown 类型和 any 类型有些类似,一个 unknown 类型的变量可以再次赋值为任意其它类型,但只能赋值给 any 与 unknown 类型的变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let unknownVar: unknown = 'jude'

unknownVar = false
unknownVar = 'jude'
unknownVar = {
site: 'baidu'
}

unknownVar = () => {}

const val1: string = unknownVar // Error
const val2: number = unknownVar // Error
const val3: () => {} = unknownVar // Error
const val4: {} = unknownVar // Error

const val5: any = unknownVar
const val6: unknown = unknownVar

unknown 和 any 的一个主要差异体现在赋值给别的变量时,any 就像是 “我身化万千无处不在” ,所有类型都把它当自己人。而 unknown 就像是 “我虽然身化万千,但我坚信我在未来的某一刻会得到一个确定的类型” ,只有 any 和 unknown 自己把它当自己人。简单地说,any 放弃了所有的类型检查,而 unknown 并没有。这一点也体现在对 unknown 类型的变量进行属性访问时:

1
2
3
let unknownVar: unknown

unknownVar.foo() // 报错:对象类型为 unknown

要对 unknown 类型进行属性访问,需要进行类型断言,即“虽然这是一个未知的类型,但我跟你保证它在这里就是这个类型!”

1
2
let unknownVar: unknown
;(unknownVar as { foo: () => {} }).foo()

在类型未知的情况下,更推荐使用 unknown 标注。这相当于你使用额外的心智负担保证了类型在各处的结构,后续重构为具体类型时也可以获得最初始的类型信息,同时还保证了类型检查的存在。当然,unknown 用起来很麻烦,一堆类型断言写起来可不太好看。归根结底,到底用哪个完全取决于你自己。

never 类型

如果说,any 与 unknown 是比原始类型、对象类型等更广泛的类型,也就是说它们更上层一些,就像 string 字符串类型比 ‘jude’ 字符串字面量更上层一些,即 any/unknown -> 原始类型、对象类型 -> 字面量类型

是否存在比字面量类型更底层一些的类型?
这里的上层与底层,其实即意味着包含类型信息的多少。any 类型包括了任意的类型,字符串类型包括任意的字符串字面量类型,而字面量类型只表示一个精确的值类型。如要还要更底层,也就是再少一些类型信息,那就只能什么都没有了。
而内置类型 never 就是这么一个“什么都没有”的类型。此前我们已经了解了另一个“什么都没有”的类型,void。但相比于 void ,never 还要更加空白一些。

举例说明 never 类型:

1
type UnionWithNever = 'jude' | 599 | true | void | never // never会被无视掉,void仍然存在

void 作为类型表示一个空类型,就像没有返回值的函数使用 void 来作为返回值类型标注一样,void 类型就像 JavaScript 中的 null 一样代表“这里有类型,但是个空类型”。

而 never 才是一个“什么都没有”的类型,它甚至不包括空的类型,严格来说,never 类型不携带任何的类型信息,因此会在联合类型中被直接移除,比如我们看 void 和 never 的类型兼容性

1
2
3
4
5
6
declare let v1: never
declare let v2: void

v1 = v2 // X 类型 void 不能赋值给类型 never

v2 = v1

never 类型被称为 Bottom Type,是整个类型系统层级中最底层的类型。

和 null、undefined 一样,它是所有类型的子类型,但只有 never 类型的变量能够赋值给另一个 never 类型变量。
通常我们不会显式地声明一个 never 类型,它主要被类型检查所使用。但在某些情况下使用 never 确实是符合逻辑的,比如一个只负责抛出错误的函数:

1
2
3
function justThrow(): never {
throw new Error()
}

在类型流的分析中,一旦一个返回值类型为 never 的函数被调用,那么下方的代码都会被视为无效的代码(即无法执行到):

1
2
3
4
5
6
7
8
9
10
11
function justThrow(): never {
throw new Error()
}

function foo(input: number) {
if (input > 1) {
justThrow()
// 等同于 return 语句后的代码,即 Dead Code
const name = 'jude'
}
}

我们也可以显式利用它来进行类型检查,即上面在联合类型中 never 类型神秘消失的原因。假设,我们需要对一个联合类型的每个类型分支进行不同处理:

1
2
3
4
5
6
7
8
9
10
11
declare const strOrNumOrBool: string | number | boolean

if (typeof strOrNumOrBool === 'string') {
console.log('str!')
} else if (typeof strOrNumOrBool === 'number') {
console.log('num!')
} else if (typeof strOrNumOrBool === 'boolean') {
console.log('bool!')
} else {
throw new Error(`Unknown input type: ${strOrNumOrBool}`)
}

如果我们希望这个变量的每一种类型都需要得到妥善处理,在最后可以抛出一个错误,但这是运行时才会生效的措施,是否能在类型检查时就分析出来?

实际上,由于 TypeScript 强大的类型分析能力,每经过一个 if 语句处理,strOrNumOrBool 的类型分支就会减少一个(因为已经被对应的 typeof 处理过)。而在最后的 else 代码块中,它的类型只剩下了 never 类型,即一个无法再细分、本质上并不存在的虚空类型。在这里,我们可以利用只有 never 类型能赋值给 never 类型这一点,来巧妙地分支处理检查:

1
2
3
4
5
6
7
8
9
10
11
if (typeof strOrNumOrBool === 'string') {
// 一定是字符串!
strOrNumOrBool.charAt(1)
} else if (typeof strOrNumOrBool === 'number') {
strOrNumOrBool.toFixed()
} else if (typeof strOrNumOrBool === 'boolean') {
strOrNumOrBool === true
} else {
const _exhaustiveCheck: never = strOrNumOrBool
throw new Error(`Unknown input type: ${_exhaustiveCheck}`)
}

假设某个粗心的同事新增了一个类型分支,strOrNumOrBool 变成了 strOrNumOrBoolOrFunc,却忘记新增对应的处理分支,此时在 else 代码块中就会出现将 Function 类型赋值给 never 类型变量的类型错误。这实际上就是利用了类型分析能力与 never 类型只能赋值给 never 类型这一点,来确保联合类型变量被妥善处理。

1
2
3
const arr = []

arr.push('jude') // 类型“string”的参数不能赋给类型“never”的参数。

此时这个未标明类型的数组被推导为了 never[] 类型,这种情况仅会在你启用了 strictNullChecks 配置,同时禁用了 noImplicitAny 配置时才会出现。解决的办法也很简单,为这个数组声明一个具体类型即可。

类型断言(附加)

类型断言能够显式告知类型检查程序当前这个变量的类型,可以进行类型分析地修正、类型。它其实就是一个将变量的已有类型更改为新指定类型的操作,它的基本语法是 as NewType,你可以将 any / unknown 类型断言到一个具体的类型:

1
2
let unknownVar: unknown
;(unknownVar as { foo: () => {} }).foo()

还可以 as 到 any 来为所欲为,跳过所有的类型检查:

1
2
3
4
5
6
const str: string = 'jude'(
// 跳过检查
str as any
)
.func()
.foo().prop

也可以在联合类型中断言一个具体的分支:

1
2
3
4
5
6
7
function foo(union: string | number) {
if ((union as string).includes('jude')) {
}

if ((union as number).toFixed() === '599') {
}
}

但是类型断言的正确使用方式是,在 TypeScript 类型分析不正确或不符合预期时,将其断言为此处的正确类型:

1
2
3
4
5
6
7
8
9
interface IFoo {
name: string
}

declare const obj: {
foo: IFoo
}

const { foo = {} as IFoo } = obj

这里从 {} 字面量类型断言为了 IFoo 类型,即为解构赋值默认值进行了预期的类型断言。当然,更严谨的方式应该是定义为 Partial 类型,即 IFoo 的属性均为可选的。
除了使用 as 语法以外,你也可以使用 <> 语法。它虽然书写更简洁,但效果一致,只是在 TSX 中尖括号断言并不能很好地被分析出来。你也可以通过 TypeScript ESLint 提供的 consistent-type-assertions 规则来约束断言风格。

双重断言

如果在使用类型断言时,原类型与断言类型之间差异过大,也就是指鹿为马太过离谱,离谱到了指鹿为霸王龙的程度,TypeScript 会给你一个类型报错:

1
2
3
4
const str: string = 'jude'

// 从 X 类型 到 Y 类型的断言可能是错误的,blabla
;(str as { handler: () => {} }).handler()

此时它会提醒你先断言到 unknown 类型,再断言到预期类型,就像这样:

1
2
3
4
5
6
const str: string = 'jude'

;(str as unknown as { handler: () => {} }).handler()

// 使用尖括号断言
;(<{ handler: () => {} }>(<unknown>str)).handler()

这是因为你的断言类型和原类型的差异太大,需要先断言到一个通用的类,即 any / unknown。这一通用类型包含了所有可能的类型,因此断言到它和从它断言到另一个类型差异不大。

非空断言

非空断言其实是类型断言的简化,它使用 ! 语法,即 obj!.func()!.prop 的形式标记前面的一个声明一定是非空的(实际上就是剔除了 null 和 undefined 类型)

1
2
3
4
5
6
7
declare const foo: {
func?: () => {
prop?: number | null
}
}

foo.func().prop.toFixed()

此时,func 在 foo 中不一定存在,prop 在 func 调用结果中不一定存在,且可能为 null,我们就会收获两个类型报错。如果不管三七二十一地坚持调用,想要解决掉类型报错就可以使用非空断言:

1
foo.func!().prop!.toFixed()

其应用位置类似于可选链:

1
foo.func?.().prop?.toFixed()

但不同的是,非空断言的运行时仍然会保持调用链,因此在运行时可能会报错。而可选链则会在某一个部分收到 undefined 或 null 时直接短路掉,不会再发生后面的调用。

非空断言的常见场景还有 document.querySelector、Array.find 方法等:

1
2
3
const element = (document.querySelector('#id')!;

const target = [1, 2, 3, 599].find(item => item === 599)!;

为什么说非空断言是类型断言的简写?因为上面的非空断言实际上等价于以下的类型断言操作:

1
2
3
4
5
6
7
;(
(
foo.func as () => {
prop?: number
}
)().prop as number
).toFixed()

可以通过 non-nullable-type-assertion-style 规则来检查代码中是否存在类型断言能够被简写为非空断言的情况。

类型断言还有一种用法是作为代码提示的辅助工具,比如对于以下这个稍微复杂的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
interface IStruct {
foo: string
bar: {
barPropA: string
barPropB: number
barMethod: () => void
baz: {
handler: () => Promise<void>
}
}
}

// 假设你想要基于这个结构随便实现一个对象,你可能会使用类型标注:
const obj: IStruct = {}
//这个时候等待你的是一堆类型报错,你必须规规矩矩地实现整个接口结构才可以。但如果使用类型断言,我们可以在保留类型提示的前提下,不那么完整地实现这个结构:
// 这个例子是不会报错的
const obj = <IStruct>{
bar: {
baz: {}
}
}