附录A:类型注释

本书主要采用JavaScript编写插件,同时通过JSDoc的方式标注变量和函数的类型信息。JSDoc语法也是TypeScript支持的,特别适合描述API的类型信息,同时保持实现的灵活性。这些简要介绍类型注释的基本用法。

A.1 基本用法

变量在声明时可以通过注释语法明确指定一个类型:

/**@type {number} */
let x;

也可以对一个值做强制转型:

let x = /**@type {any}*/(0);

给函数增加类型信息:

/**
 * @param  {number} a
 * @param  {...number} more
 * @return {number}
 */
function sum(a, ...more) {
	for(let i = 0; i < more.length; i++) {
		a += more[i];
	}
	return a;
}

参数a是数值类型,more是可变数值类型参数,返回值是数值类型。

如果是函数类型的变量或常量类型,可以这样表示:

/**@type {function(number, number):number} */
let sum2;

/**@type {(a:number, b:number) => number} */
let sum3;

如果是遇到不好表示的类型可以暂时忽略,毕竟我们优先选择JavaScript的因素是方便先实现功能。

A.2 基础类型

基础类型是JavaScript已有的基础类型,主要有booleannumberstring等。基础类型直接通过内置的名字就可以引用:

/**@type {boolean} */
let b0;

/**@type {number} */
let i0;

/**@type {string} */
let s0;

上面的例子就是定义了三个对于三种基本类型的变量。通过组合基础类型可以构造更复杂的类型。JavaScript种除了基础类型之外,还有objectany表示的对象。在这里不用纠结这些类型在运行时的差异,只要是TypeScript内置名字的类型都可以看作是基础类型,比如unknown等在编译阶段也可以算是基础类型。此外还有仅针对函数返回值的void类型。

A.3 复合类型

复合类型是通过组合基础类型或者是其它的复合类型,组合有数组、元组、联合、对象等多种不同的方式。以下是复合类型的一些例子:

/**@type {boolean[]} */
let array0;

/**@type {[number,string]} */
let tulpe0;

/**@type {number|string} */
let union0;

/**@type { {a:boolean, b:number, c:object} } */
let object0;

/**@type { (number|string)[] } */
let array_union;

其中array0是在布尔基础类型之上通过数组的方式构建出布尔数组复合类型。而tulpe0是基于数值和字符串构造的元组类型。union0则是可以表示数值或字符串之一的联合类型。然后object0通过对象方式构造出一个有三个不同类型成员的对象类型。最后的array_union则是在联合类型的基础之上又结合数组组合方式构造的联合数组类型变量。

A.4 命名类型

不管是基础类型还是复合类型,只有底层的类型组合方式完全一致就是相同的类型。而命名类型只是为了方便表示,命名类型和原始的类型依然是同一种类型。

通过@typedef可以给类型命名:

/**@typedef { boolean } MyBool*/

/**@type {boolean} */
let b2;

/**@type {MyBool} */
let b3 = b2;

在这里boolean被重新命名为MyBool。不过两者依然是相同的类型,因此b2b3可以不用强制转型直接进行相互赋值。

通过@typedef还可以给复合类型命名:

/**@typedef { {x:number, y:number, z:number} } Point*/

/**@type {Point} */
let pt1 = null;

这里例子定义了一个Point点对象类型,每个点对象必须有xyz三个成员表示三维坐标信息。

A.5 类型导入

类型导入是指从其它的JavaScript模块导入类型。为了方便演示,我们先构造一个lib.js文件:

/**
 * @typedef { {Name:string, Age:number} } Person
 */

/**@type {Person} */
export let gopher = {
	Name: "golang",
	Age: 10,
};

其中定义了一个Person类型,定义并且导出了一个该类型的变量gopher。然后我们创建main.js文件,并在其中导入lib.js模块中的类型:

/**@type { import("./lib.js").Person } */
let clang;

注释中的import("./lib.js").Person引用的就是导入lib.js模块中的类型。除了可以直接引用导入模块的类型之外,我们也可以从导入模块的变量或常量推导类型:

/**@type { typeof import("./lib.js").gopher } */
let cpplang;

注释中的typeof import("./lib.js").gopher根据lib.js模块导出的gopher变量推导出Person类型信息。甚至我们可以将从导入模块引用或者推导出的类型再次命名。

这时候x将明确指定为数值类型。但是typeof x在运行时输出的依然是undefined。因此TypeES6编程实践中的类型更多的是开发阶段的类型信息,所以的编译阶段的类型信息在运行时将不再存在。

在编译阶段(或者叫开发阶段),对于指定了数值类型的变量将不再接受字符串赋值:

/**@type {number} */
let x;
x = 'abc';

我们也可以用以下两种方式明确指定数组的类型:

/**@type {number []}*/
let list2 = [1, 2, 3];

let list3 = /**@type {number[]}*/([1, 2, 3]);

其中第一个语句是在声明时通过TypeES6的注释语法将list2变量声明为number []类型。第二个语句则是通过将初始化的数组值[1, 2, 3]转型为number[]类型后赋值给list3变量,这样list3变量就可以根据初始化的值类型推导出自己的类型。

需要的注意的是,在给初始值转型时千万要用小括号将值包括起来,否则会导致类型注释不能生效。比如下面的写法是错误的:

// 错误: 类型注释没有生效, 缺少了小括号
let list4_failed = /**@type {number[]}*/[1, 2, 3];

虽然最终list4_failed变量依然是number[]类型,但是这个类型却不是由/**@type {number[]}*/类型注释产生,而是因为[1, 2, 3]默认就是number[]类型的数组。

比如下面的代码将无法即使发现错误:

// 错误: 类型注释没有生效, 缺少了小括号
let list5_failed = /**@type {number[]}*/[1, 2, '3'];

这时候初始值默认是(number|string)[]类型,而不是我们注释的number[]类型。下面通过添加缺少的小括号让类型注释生效:

let list5_ok1 = /**@type {number[]}*/([1, 2, '3']);
let list5_ok2 = /**@type {(number|string)[]}*/([1, 2, 'a']);

这样的话,list5_ok1变量就是明确的number[]类型,而list5_ok2则是(number|string)[]类型。现在虽然变量是我们期望的类型,但是list5_ok1变量初始值的第三个元素被错误地写成了字符串类型,而且命令行没有任何错误提升!

我们采用第一种指定变量类型的写法,这样可以发现初始值不匹配的错误:

/**@type {number []}*/
let list5_ok3 = [1, 2, 'a'];

最后,我们依然可以定义any类型的数组:

/**@type {any[]}*/
let list6 = [true, 1, 'a', {}, null];

这样的话,我们就可以在list6数组变量中存放不同类型的元素。不过这样也就丢失TypeES6的类型检查优势,因此建议谨慎使用any类型的数组。