写在前面
TypeScript 的类型检查不仅限于.ts
,还支持.js
但为了确保文件内容只含有标准的 JavaScript 代码,.js
文件按照 ES 语法规范来检查,因而不允许出现 TypeScript 类型标注:
.js
files are still checked to ensure that they only include standard ECMAScript features; type annotations are only allowed in.ts
files and are flagged as errors in.js
files.
所以通过JSDoc来给 JavaScript 添加额外的类型信息:
JSDoc comments can be used to add some type information to your JavaScript code, see JSDoc Support documentation for more details about the supported JSDoc constructs.
同时,针对.js
的类型检查相对宽松一些,与.ts
的类型检查有所不同,差异主要集中在 3 方面:
类型标注方式
默认类型
类型推断策略
P.S.由于宽松策略,noImplicitAny
、strictNullChecks
等严格校验标记在.js
里也不那么可靠
一.开启检查
--allowJs
选项允许编译 JavaScript 文件,但默认不会对这些文件做类型检查。除非再开启--checkJs
选项,会对所有的.js
文件进行校验
Option | Type | Default | Description |
---|---|---|---|
--allowJs |
boolean |
false |
Allow JavaScript files to be compiled. |
--checkJs |
boolean |
false |
Report errors in .js files. Use in conjunction with –allowJs. |
另外,TypeScript 还支持一些用来控制类型检查的特殊注释:
// @ts-nocheck
:文件级,跳过类型检查// @ts-check
:文件级,进行类型检查// @ts-ignore
:行级,忽略类型错误
这些注释提供了更细粒度的类型检查控制,比如只想检查部分.js
文件的话,可以不开启--checkJs
选项,仅在部分.js
文件首行添上// @ts-check
注释
二.类型标注方式
.js
文件里通过 JSDoc 来标注类型,例如:
/**
* @type {number}
*/
var x;
x = 0;
// 报错 Type 'false' is not assignable to type 'number'.
x = false;
注意,JSDoc 对注释格式有要求,以/**
开头的才认:
JSDoc comments should generally be placed immediately before the code being documented. Each comment must start with a
/**
sequence in order to be recognized by the JSDoc parser. Comments beginning with /*, /***, or more than 3 stars will be ignored.
(摘自Adding documentation comments to your code)
另外,并非所有 JSDoc 标记都支持,白名单见Supported JSDoc
三.默认类型
另一方面,JavaScript 里存在大量惯用“模式”,所以在默认类型方面相当宽松,主要表现为 3 点:
函数参数默认可选
未指定的类型参数默认
any
类型宽松的对象字面量
函数参数默认可选
.js
文件里所有函数参数都默认可选,所以允许实参数量少于形参,但存在多余参数时仍会报错,例如:
function bar(a, b) {
console.log(a + " " + b);
}
bar(1);
bar(1, 2);
// 错误 Expected 0-2 arguments, but got 3.
bar(1, 2, 3);
注意,通过 JSDoc 标注了参数必填时例外:
/**
* @param {string} greeting - Greeting words.
* @param {string} [somebody] - Somebody's name.
*/
function sayHello(greeting, somebody) {
if (!somebody) {
somebody = 'John Doe';
}
console.log('Hello ' + somebody);
}
// 错误 Expected 1-2 arguments, but got 0.
sayHello();
sayHello('Hello');
sayHello('Hello', 'there');
// 错误 Expected 1-2 arguments, but got 3.
sayHello('Hello', 'there', 'wooo');
根据 JSDoc 标注,上例中greeting
必填,somebody
可选,因此无参和 3 参会报错
特殊的,ES6 可以通过默认参数和不定参数来隐式标记可选参数,例如:
/**
* @param {string} somebody - Somebody's name.
*/
function sayHello(somebody = 'John Doe') {
console.log('Hello ' + somebody);
}
// 正确
sayHello();
从 JSDoc 标注(@param {string} somebody
)来看somebody
必填,默认参数(somebody = 'John Doe'
)表明somebody
可选,类型系统会综合这些信息进行推断
未指定的类型参数默认any
JavaScript 没有提供用来表示泛型参数的语法,因此未指定的类型参数都默认any
类型
泛型在 JavaScript 中主要以 2 种形式出现:
继承泛型类,创建 Promise 等(泛型类、Promise 等定义在外部
d.ts
里)其它自定义泛型(通过 JSDoc 标明泛型类型)
例如:
// 继承泛型类 - .js
import { Component } from 'react';
class MyComponent extends Component {
render() {
// 正确 this.props.unknownProp 是 any 类型
return <div>{this.props.unknownProp}</div>
}
}
其中this.props
具有泛型类型:
React.Component<any, any, any>.props: Readonly<any> & Readonly<{
children?: React.ReactNode;
}>
因为在.js
里没有指定泛型参数的类型时,默认为any
,所以不报错。但同样的代码在.tsx
里会报错:
// .tsx
import { Component } from 'react';
class MyComponent extends Component {
render() {
// 错误 Property 'unknownProp' does not exist on type 'Readonly<{}> & Readonly<{ children?: ReactNode; }>'.
return <div>{this.props.unknownProp}</div>
}
}
Promise 的场景也类似:
// .js
var p = new Promise((resolve, reject) => { reject(false) });
// p 类型为 Promise<any>
p;
// .ts
const p = new Promise<boolean>((resolve, reject) => { reject(false) });
// p 类型为 Promise<boolean>
p;
除了这种来自外部声明(d.ts
)的泛型外,还有一种自定义的“JavaScript 泛型”:
// .js 声明泛型,但不填类型参数
/** @type{Array} */
var x = [];
x.push(1); // OK
x.push("string"); // OK, x is of type Array<any>
// .js 声明泛型,同时指定类型参数
/** @type{Array.<number>} */
var y = [];
y.push(1); // OK
y.push("string"); // Error, string is not assignable to number
即通过 JSDoc 定义的泛型,若未指定类型参数,就默认any
类型宽松的对象字面量
.ts
里,用对象字面量初始化变量的同时会确定该变量的类型,并且不允许往对象字面量上添加新成员,例如:
// .ts
// obj 类型为 { a: number; }
let obj = { a: 1 };
// 错误 Property 'b' does not exist on type '{ a: number; }'.
obj.b = 2;
在.js
里则相对宽松:
// .js
var obj = { a: 1 };
// 正确
obj.b = 2;
就像具有索引签名[x:string]: any
一样;
// .ts
let obj: { a: number; [x: string]: any } = { a: 1 };
obj.b = 2;
同样,在 JavaScript 也可以通过 JSDoc 标明其确切类型:
// .js
/** @type {{a: number}} */
var obj = { a: 1 };
// 错误 Property 'b' does not exist on type '{ a: number; }'.
obj.b = 2;
四.类型推断策略
类型推断分为赋值推断与上下文推断,对于.js
有一些针对性的推断策略
赋值推断:
Class 成员赋值推断
构造函数等价于类
null
、undefined
、[]
赋值推断
上下文推断:
不定参数推断
模块推断
命名空间推断
Class 成员赋值推断
.ts
里通过类成员声明中的初始化赋值来推断实例属性的类型:
// .ts
class Counter {
x = 0;
}
// 推断 x 类型为 number
new Counter().x++;
而 ES6 Class 没有提供声明实例属性的语法,类属性通过动态赋值来创建,对于这种 JavaScript 惯用“模式”也能进行推断,例如:
class C {
constructor() {
this.constructorOnly = 0;
this.constructorUnknown = undefined;
}
method() {
// 错误 Type 'false' is not assignable to type 'number'.
this.constructorOnly = false;
this.constructorUnknown = "plunkbat";
this.methodOnly = 'ok';
}
method2() {
this.methodOnly = true;
}
}
class
声明中的所有属性赋值都会作为(类实例)类型推断的依据,所以上例中C
类实例的类型为:
// TypeScript
type C = {
constructorOnly: number;
constructorUnknown: string;
method: () => void;
method2: () => void;
methodOnly: string | boolean
}
具体规则如下:
属性类型通过构造函数中的属性赋值来确定
对于没在构造函数中定义,或者构造函数中类型为
undefined
或null
(此时为any
)的属性,其类型为所有赋值中右侧值类型的联合定义在构造函数中的属性都认为是一定存在的,其它地方(如成员方法)出现的都当作可选的
类声明中未出现的属性都是未定义的,访问就报错
构造函数等价于类
另外,在 ES6 之前,JavaScript 里用构造函数代替类,TypeScript 类型系统也能够“理解”这种模式(构造函数等价于 ES6 Class),成员赋值推断同样适用:
function C() {
this.constructorOnly = 0;
this.constructorUnknown = undefined;
}
C.prototype.method = function() {
// 错误 Type 'false' is not assignable to type 'number'.
this.constructorOnly = false;
this.constructorUnknown = "plunkbat";
}
null
、undefined
、[]
赋值推断
.js
里,初始值为null
、undefined
的变量、参数或属性都视为any
类型,初始值为[]
的则视为any[]
类型,例如:
// .js
function Foo(i = null) {
// i 类型为 any
if (!i) i = 1; // i 类型仍为 any
var j = undefined; // j 类型为 any
j = 2; // j 类型为 any | number 即 number
this.j = j;
this.l = []; // this.l 类型为 any[]
}
var foo = new Foo();
foo.l.push(foo.j);
foo.l.push("end");
同样,多次赋值时,类型为各值类型的联合
不定参数推断
.js
里会根据arguments
的使用情况来推断是否存在不定参数,例如:
// .js
function sum() {
var total = 0
for (var i = 0; i < arguments.length; i++) {
total += arguments[i]
}
return total
}
// sum 类型为 (...args: any[]) => number
sum(1, 2, 3);
当然,也可以通过 JSDoc 声明不定参数:
// .js
/** @param {...number} args */
function sum(/* numbers */) {
var total = 0
for (var i = 0; i < arguments.length; i++) {
total += arguments[i]
}
return total
}
// sum 类型为 (...args: number[]) => number
sum(1, 2, 3);
模块推断
在.js
里,对于 CommonJS 模块,会把exports
,module.exports
的属性赋值识别为模块导出(export
),而require
函数调用则对应到模块引入(import
),例如:
// .js
// 等价于 `import module "fs"`
const fs = require("fs");
// 等价于 `export function readFile`
module.exports.readFile = function(f) {
return fs.readFileSync(f);
}
P.S.实际上,TypeScript 对 CommonJS 模块的支持就是通过这种类型推断来完成的
命名空间推断
.js
里,类、函数和对象字面量都视为命名空间,因为它们与命名空间非常相似(都具有值和类型的双重含义、都支持嵌套、并且三者能够结合使用)。例如:
// .js
class C { }
C.D = class { }
// 或者
function Cls() {}
Cls.D = function() {}
new C.D();
new Cls.D();
尤其是对象字面量,在 ES6 之前本就用作命名空间:
var c = {};
ns.D = class {}
ns.F = function() {}
new c.D();
new c.F();