TypeScript 的类型系统

在 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' },
];
function renderUser(id, name) {
  return `<div id="${id}">${name.toUppercase()}</div>`;
}
const html = users.map((x) => renderUser(x.name, x.id)).join('\n');

JS 的松散语法给我们带来便利的同时,也带来了一些隐患:一些(通常是低级的)错误,要等到运行时才会抛出来。在上边的例子中很明显,在调用 renderUser 时,传入的参数顺序反了。

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

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

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

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

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

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

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

有了类型加成之后,重构代码变得如此轻松:

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

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

  • 在线
  • 本地
    • 安装 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:布尔型,即 false。
  • null:即 null。
  • undefined:即 undefined。
  • symbol:符号类型。

TS 还提供了类型 void,它等于 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.sinMath.abs 等。newPoint 类型表示用两个坐标构造出 point2D 的构造函数。string ∪ number 集合上的恒等映射就满足 returnSelf 类型。

签名(Signature)

对象类型的单一属性、单一函数类型叫做一条签名。例如上文中 point2Dx: 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 称作 stringnumber 的联合类型。一个 text 类型的变量,既可以被赋值为字符串,又可以被赋值为浮点数。

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

对于一个 string 类型的变量,我们可以访问它的 toUpperCasesplitlength 属性或方法;可以访问 number 类型变量的 toFixedtoString 等方法。而对于 text 类型的变量,我们只能访问 stringnumber 的共有方法 toStringvalueOf,其他属性或方法都不保证存在。

交叉类型

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 = anyany 类型在任意运算中都是有传染性的);全集并上任意集合还是全集 T ∪ any = any

never 类型对应空集。任何值,即使是 undefinednull 也不能赋值给 never 类型。对于任意类型 T, T ∩ never = neverT ∪ 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。

anynever

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