APP下载

深入 TypeScript 的型别系统

消息来源:baojiabao.com 作者: 发布时间:2024-05-19

报价宝综合消息深入 TypeScript 的型别系统

原文:https://zhuanlan.zhihu.com/p/38081852

在2017年,TypeScript 已经占领了前端非原生语言市场的主导地位。node 的后继者 deno 也是构建在 TypeScript 之上的。本文将介绍型别系统为我们带来了什么好处,然后从集合的角度探一探型别系统的究竟,并介绍 TypeScript 在可靠性和生产力之间做了哪些平衡。

〇、TS 是什么

考虑这样一段 JS 程式码。你能一眼看出其中的问题吗?

const users = [

{ id: 1, name: \'alice\' },

{ id: 2, name: \'bob\' },

{ id: 3, name: \'cathy\' },

{ id: 4, name: \'daniel\' },

]

functionrenderUser(id, name) {

return`${name.toUppercase()}`

}

const html = users

.map(x => renderUser(x.name, x.id))

.join(\' \')

JS 的松散语法给我们带来便利的同时,也带来了一些隐患:一些(通常是低阶的)错误,要等到执行时才会丢掷来。在上边的例子中很明显,在呼叫 renderUser 时,传入的引数顺序反了。

JS 的值,数字归数字、物件归物件、本身都是有型别的,但这种型别是隐式的、可变的。Python 之禅有一条是说:显式优于隐式,如果有一种办法显式地把型别告诉我们的机器,机器是不是能反馈给我们一些好处?

于是人们发明了 JSDoc,用 /** @type */ 注释来标记型别。一些程式码编辑器开始跟进,以提供更好的程式码检查与智慧提示。然而 JSDoc 并没有一个标准规范告诉人们 @type 后面写什么,因为它更偏重于文件功能;而且标注函式和复杂物件时显得力不从心。所以 JSDoc 不能说是一流的型别解决方案。

如果我们觉得有必要做型别,是不是可以考虑把型别放在更显著的位置上,比如创造一些额外语法来支援型别?这个思路下的解决方案,就是本文的主角 TypeScript(以下简称 TS)。

我们为函式 renderUser 的入参标注好型别之后,TS 立刻检测出了第 11 行的引数型别错误。注意,我们并没有标注过 users 的型别,它的型别乃至第 11 行 x 的型别都是根据先前赋值自动推断出来的。

同样在第 11 行可以注意到,在键入 x. 之后,编辑器已经给出了提示:x 有两个属性 id和 name。精确的自动补全功能,是型别系统带来的额外便利。

一个用当前文件中的词语实现的自动补全功能,像是从原始时代来的。 —— Felix Rieseberg

另外,TS 还帮我们检查出了第八行的笔误。笔误严格说来属于型别错误,不过更进一步:TS 会在已知的的属性名或方法名中寻找可能存在的正确名称。

有了型别加成之后,重构程式码变得如此轻松:

以上,相信即使是初见者也能快速明白 TS 的两大好处:防患于未然的静态检查,以及干净利落的智慧提示。这一切都是建立在它的型别系统之上的。

如何玩转示例程式码?两种方案二选一:

线上

开启 http://www.typescriptlang.org/play/index.html ;

点选 Options 把选项全部勾选上;

贴入示例程式码到左侧文字框。

本地

安装 VS Code;

新建一个空资料夹,在里边建立一个 tsconfig.json 档案,其内容为 { "compilerOptions": { "strict": true } };

建立 index.ts 档案,贴入示例程式码。

一、型别是什么

型别是所有满足某些特征的 JS 值的集合。举个例子,number 型别,是所有浮点数、NaN、±Infinity、-0 的集合。

我们知道,集合具有下列三个特征:

确定性:给定一个元素,可以明确地判断其是否属于该集合。互异性:集合中不存在两个相同的元素。无序性:集合中的元素任意排列,仍然表示相同的集合。在一个型别中,我们不关心值的次序,也不存在重复的值。由于型别是用程式码精确地定义的,那么 TS 语言服务总能判断一个值是否满足某个型别的要求。

事实上,要精确地判断一件事物是否是集合,需要一堆抽象的废话。自从罗素悖论被提出后,人们才发现朴素集合论自身的矛盾性。现在为了定义集合,应用最广的是 ZF 公理系统。其中有一条分离公理:一个集合(所有 JS 的值,由 ECMA 规范定义)中,抽出所有满足某命题的元素(TS 判断是否满足某型别),可以构成一个新的集合。

集合的外延与内涵

一个集合可以从外延和内涵两个方面来看待。谈到外延时,指的集合中一切元素的全体:整数集的外延就是 {..., -2, -1, 0, 1, 2, ...}。谈到内涵时,指的是集合中所有元素的公有属性:整数集的内涵是每个元素都能被 1 整除。

整数集中去掉所有负数后,就有了新的内涵:每个元素都大于等于 0 。一个集合的外延越小,其内涵越多,反之亦然。

二、型别速览

原始型别

对应 JS 的原始型别,TS提供了如下几种原始型别:

number:包括浮点数,以及 NaN、±Infinity。

string:字串型。

boolean:布林型,即 { true, false }。

null:即 { null }。

undefined:即 { undefined }。

symbol:符号型别。

TS还提供了型别 void,它等于 { null, undefined }。

另外,所有原始型别的字面量本身也可作为型别使用,其外延只包括自身。

物件型别

通过类似 JS 物件字面量的方式来定义型别。

type point2D = {

x: number

y: number

}

const center: point2D = { x: 0, y: 0 }

物件的键也可以不精确到特定键名:

type httpHeaders = {

[key string]: string | undefined // "|" 表示“或者”

}

这表示对于 httpHeaders 型别的值,以任意字串作下标取值都是合法的,值的型别为字串或者 undefined。

函式型别

函式型别分为两种,普通呼叫和构造呼叫,其区别在于呼叫时是否带有 new 关键字。

type unary = (x: number) => number

type newPoint = new (x: number, y: number) => point2D

type returnSelf = { // 多型的函式

(x: string): string

(x: number): number

}

这里我们暂时不要关心上述函式型别的具体实现。unary 型别是数字的一元运算,Math 库里的许多函式都是此型别,如 Math.sin、Math.abs 等。newPoint 型别表示用两个座标构造出 point2D 的建构函式。string ∪ number 集合上的恒等对映就满足 returnSelf 型别。

签名(Signature)

物件型别的单一属性、单一函式型别叫做一条签名。例如上文中 point2D,x: number 就是它的一条签名。介绍此概念,是为了下文更方便地理解子型别。

签名(Signature)分为三种,call, construct 和 index,分别对应上述的普通呼叫、构造呼叫和物件型别。它们的形式是类似的:

/** 确实存在 strangeThing 型别的物件! */

/** f 就满足 strangeThing 型别。

function f(x) {

if (this.constructor === f) return {}

return x

}

f.foo = \'bar\'

*/

type strangeThing = {

foo: string // index

(x: number): number // call

new (): {} // construct

}

/** 可以认为每一行签名就是型别的内涵。 */

签名可以类比成集合间的对映。 它把冒号左边的原像集、连同签名方式,对映到右边的像集。

三、型别运算

联合型别

示例中,text 称作 string 和 number 的联合型别。一个 text 型别的变数,既可以被赋值为字串,又可以被赋值为浮点数。

也就是说,text 作为集合是 string 和 number 的并集。两个集合的并集,其内涵只包括原来两个集合的共有内涵。

对于一个 string 型别的变数,我们可以访问它的 toUpperCase、split、length 属性或方法;可以访问 number 型别变数的 toFixed、toString 等方法。而对于 text 型别的变数,我们只能访问 string 和 number 的共有方法 toString 和valueOf,其他属性或方法都不保证存在。

交叉型别

type landAnimal = {

name: string

canLiveOnLand: true

}

type waterAnimal = {

name: string

canLiveInWater: true

}

// 交叉型别:两栖动物

type amphibian = landAnimal & waterAnimal

let toad: amphibian = {

name: \'toad\',

canLiveOnLand: true,

canLiveInWater: true,

}

交叉型别的含义为:符合型别 A 和 B 的交叉型别的值,既符合型别 A,又符合型别 B。

类比前文的联合型别,交叉型别可以认为是两个型别的交集。其内涵覆盖了原来两个集合的所有内涵。

从较抽象的层面看来,你也可以认为 { x: number, y: number } 和 { x: number } & { y: number } 是一回事儿,尽管 TS 在具体实现上有细微的差异。

全集和空集

any 型别,顾名思义,泛指一切可能的型别,对应全集。

理论上,任意集合交上全集保持不变 T ∩ any = T(实际上 T & any = any,any 型别在任意运算中都是有传染性的);全集并上任意集合还是全集 T ∪ any = any。

never 型别对应空集。任何值,即使是 undefined 或 null 也不能赋值给 never 型别。对于任意型别 T, T ∩ never = never,T ∪ never = T。TS 也是如此实现的。

那么 never 型别在实际应用中如何才会出现呢?

一个中途丢掷错误,或者存在死循环的函式永远不会有返回值,其返回型别是 never。在某些情况下,TS 会将空阵列推断成 never 型别,这是因为在实际中,空阵列经常被作为预设值使用。

四、型别相容性

考虑以下程式码。 point2D 可以被当做 point1D 来处理,反之不行。我们说 point1D 型别相容 point2D。

为什么?因为我们所需要的 point1D 的所有内涵(属性 x),在 point2D 中均存在;反过来,point1D 不存在属性 y。也就是说,point2D 的内涵涵盖了 point1D,是它的子集。子集的外延小于父集,内涵大于父集。

我们也可以从更加“集合论”的角度看待这个现象,作为 point1D 与另外一个型别的交集,point2D 显然是其子集。

point2D = { x: number, y: number }

= { x: number } ∩ { y: number }

= point1D ∩ { y: number }

当且仅当型别 B 是 A 的子集时,A 相容 B,B 可以被当成 A 处理。

结构子型别

TS 采用结构子型别。其含义是:两个型别,即使表示的业务含义大相径庭,只要结构上有从属关系,就是相容的。(“等同”也是从属关系的一种)

type Box = {

/** 箱子的容积 */

volumn: number

}

type Speaker = {

/** 扬声器音量 */

volumn: number

}

let box: Box = { volumn: 3000 }

let speaker: Speaker = { volumn: 20 }

box = speaker // 允许赋值

与此相对的是名义子型别:只有显示宣告的子型别才算子型别,否则即使结构相同,也不能互相赋值。C++、Java 均采用此方案。

签名

现在我们来深入处理签名的本质。前文说到,签名是集合间的对映。该如何判断两个对映之间是否有父子关系?

考虑如下四个型别。这里的 \'say\'、\'hi\' 都是型别,且是 string 的子集(子型别)。

/** 等价于 { say: string },只是在此上下文中统一了写法 */

type T1 = { [key in \'say\']: string }

type T2 = { [key in \'say\']: \'hi\' }

type T3 = { [key in string]: string }

type T4 = { [key in string]: \'hi\' }

我们来两两分析型别间的关系:

T1、T2:假如可以用 notHi 来表示所有非 \'hi\' 的字串,那么 T1 = { [key in \'say\']: \'hi\' } ∪ { [key in \'say\': notHi },所以 T1 是 T2 的父集。T1、T3:假如可以把所有字串遍历出来,那么 T3 可以写成:{

...

sax: string

say: string

saz: string

...

}

那么 T3 的内涵比 T1 多得多,所以 T1 也是 T3 的父集。

类似地,我们知道 T2 是 T4 的父集,T3 是 T4 的父集。T1 作为 T2 的父集当然也是 T4 的父集。那么 T2 和 T3 之间有父子关系吗?答案是没有。我们可以轻易地证明这一点:物件 { say: \'bye\' } 满足 T3 但不满足 T2;{ say: \'hi\', numericKey: 0 } 满足 T2 但不满足 T3。综上所述,当对映的原像集(冒号左侧)外延扩张、或者像集(冒号右侧)外延收缩时,产生一个新的子集。

函式型别

说完了签名,为什么要单独讨论函式?因为与下标索引不同的是,函式的引数长度是可变的。

如果呼叫时传入的引数多于定义时所需的,那么多余的引数会被无情忽视。考虑这个函式:

function f() { return 0 }

f() // 0

f(0) // 0

f(\'hahaha\') // 0

f(null, false, {}, 233) // 0

我们知道 f 的型别是 () => number,问题:型别 () => number 和 (x: number) => number 是什么关系?

为了便于理解,考虑一种等价形式:观察函式的引数阵列(arguments)和返回值的关系。那么 f 的型别等价于 (args: {}) => number,后者的型别等价于 (args: { 0: number }) => number。

这样把 0 个或多个引数考虑成一个引数,就可以应用上节结论:当函式新加一个引数定义时,也就增加了一条原像集的内涵,收缩了其外延,得到原函式型别的父集。

总结一下,当一个签名型别:

增加一条签名;扩充套件某条签名的原像集外延;删掉一个函式签名的尾部引数(扩充套件了原像集外延);收缩某条签名的像集外延;都是增加了其内涵,得到了一个子型别。

五、平衡

有人会问了,知道怎么判断子型别有意义吗?TS 不就是帮我们做这件事情的吗?这句话并不全对。翻开 TS 的设计初心,Non-goals 的第三条赫然写道:

TS 的设计目标不是为了提供一个学术意义上严谨的型别系统,而是力图达到严谨性和生产力的平衡。

像 Elm 的型别系统就是严谨性的极端,它声称可以完全避免执行时错误。但在实际使用中,这种严谨性会损害生产力,因为它强迫开发者做很多边界处理,尽管这些边界情况被达到的频率未必值得花时间去处理。

很多时候 TS 会为了实用性,允许一些不严谨的操作。下面我将一一举例,希望读者今后在使用 TS 时,能做到心中有数。

这些不是 bug,是 feature。

any 和 never

any 型别本来没有任何实质内涵,所以访问一个 any 型别变数的任意属性或方法都是不可靠的。然而 TS 引入 any 型别却恰恰是为了允许随意访问某变数。常见情况是,在一个 TS 专案中引入一个没有型别的第三方库,只要把这个库的入口物件宣告成 any 即可。

never 型别作为另一个极端,它的内涵是无穷多的。访问 never 型别变数的任意属性都是理论上可行的,虽然没有意义,因为 never 型别并不会有例项。

函式引数中的 () => void

function expectVoid(f: () => void) {

const result = f()

return (result === undefined || result === null)

}

expectVoid(() => 1) // false

逻辑上说,expectVoid 应该永远返回 true 才对,而且这里应该丢掷型别错误,因为 () => void 和 () => number 并没有父子关系。

然而,TS 认为 () => number 是可以当作 () => void 来使用的。这是因为很多库的作者会把回拨函式的型别写成 () => void,他们的真正意图是:“我并不关心回拨函式返回什么,因为我不会处理它的值。”更为贴切的型别是 () => any。像例子中,真正期望 void 返回值的情况是极少的。

字典表

type stringMap = {

[key: string]: string

}

let map: stringMap = {

say: \'hi\',

}

map.hello.toUpperCase() // 执行时错误!

前文中讨论过,{ say: \'hi\' } 作为型别,与 { [key: string]: string } 之间是没有父子型别关系的。

TS 允许如此赋值的原因是:在实际中,处理字典表一般要先用 Object.keys 取键,再进行下一步操作。况且要是严谨起来的话,那赋值的时候要把所有的字元键名都写一遍才对,这是不可能的。

如果程式码中硬编码有 hello 这个键,应该把 hello: string 这条签名单独加在型别中。

六、总结

本文用理科的视角介绍了一遍 TypeScript,并未涉及工程方面。如需了解泛型、模组、类与界面等,大部分其他文章都有介绍,或者参阅官方文件: TypeScript Handbook。

2019-12-31 09:52:00

相关文章