第三小节:代码的最终生成跟类型无关
在更高一级中,tsc(TS编译器)做两件事情:
- 将新一代TS/JS 转化为一个可以在浏览器运行的更老的JS版本。
- 检测代码的类型错误。
出乎意料的是这两种行为互相之间完全独立。换一种说法就是你代码的类型不会影响TS跟JS的最终转化。因为最终执行的是JS,这意思就是类型不会影响代码的最终运行方式。
下面有一些意料之外的影响,并且告诉你对于TS,能做什么,不能做什么。
有类型错误的代码也能输出
因为代码的输出是独立于类型检测的。下面看个例子:
$ cat test.ts
let x = 'hello';
x = 1234;
$ tsc test.ts
test.ts:2:1 - error TS2322: Type '1234' is not assignable to type 'string'
2 x = 1234;
~
$ cat test.js
var x = 'hello';
x = 1234;
如果你是写C或者Java这种类型检测跟输出是紧密相连的语言,那么上面的例子会令你感到意外。你可以把TS的错误当作是这些语言中的警告,就像是在表明这是一个值得去调查的问题,但是不会阻止构建。
编译跟类型检查
This is likely the source of some sloppy language that is common around TypeScript.
You’ll often hear people say that their TypeScript “doesn’t compile” as a way of saying
that it has errors. But this isn’t technically correct! Only the code generation is
“compiling.” So long as your TypeScript is valid JavaScript (and often even if it isn’t),
the TypeScript compiler will produce output. It’s better to say that your code has
errors, or that it “doesn’t type check.(贴上原文)
这可能是常见的一些松散语言像TS的来源。你经常会听见人们说他们的TS不编译,以此来表示它是错的。但这在技术上来说不对!只有生成代码才是编译。只要TS是有效的JS(即使通常不是),TS编译器就能输出。最好说是你的代码有错误,
或者没有进行类型检查。(有些拗口,求更准确地翻译)
在有错误的情况下代码还能跑在实际中会很有用。如果你正在构建一个web应用,你可以知道特定的一块代码有问题。但是,因为TS在有错误的情况下还是会生成代码,所以,在修复之前,你可以先测试应用的其他部分。
当你提交代码的时候,应该保证零错误,以免落入不得不记住什么是预期或者预期之外错误的境地。如果你想在有错误的时候禁止输出,那么你可以使用 noEmitOnError 配置项,或者在构建工具中跟它相同的配置。
不能在运行时检查TS类型
你可能会写下面的代码:
interface Square {
width: number;
}
interface Rectangle extends Square {
height: number;
}
type Shape = Square | Rectangle;
function calculateArea(shape: Shape) {
if (shape instanceof Rectangle) {
// ~~~~~~~~~ 'Rectangle' only refers to a type,
// but is being used as a value here
return shape.width * shape.height;
// ~~~~~~ Property 'height' does not exist
// on type 'Shape'
} else {
return shape.width * shape.width;
}
}
instanceof 检测发生在运行时,但是Rectangle是一个类型,所以它不能影响代码的运行时行为。TS类型是可消除的:编译成JS的部分操作,就是从代码中移除所有的接口,类型,还有类型注释。
为了弄清楚你正在处理的情况的类型,你需要一些在运行时重构类型的方法。在下面的例子中,你可以检查是否存在一个 height 属性:
function calculateArea(shape: Shape) {
if ('height' in shape) {
shape; // Type is Rectangle
return shape.width * shape.height;
} else {
shape; // Type is Square
return shape.width * shape.width;
}
}
之所以这样,是因为在运行时属性检测只对值有用,但还是允许类型检测器把参数shape的类型改为Rectangle。
另一种方式是引入标签,用运行时可用的方式来显式的存储类型:
interface Square {
kind: 'square';
width: number;
}
interface Rectangle {
kind: 'rectangle';
height: number;
width: number;
}
type Shape = Square | Rectangle;
function calculateArea(shape: Shape) {
if (shape.kind === 'rectangle') {
shape; // Type is Rectangle
return shape.width * shape.height;
} else {
shape; // Type is Square
return shape.width * shape.width;
}
}
这里的 Shape 类型是一个标签联合类型的例子。因为它们使得在运行时回复类型信息变得很容易,所以标签联合在TS中很普遍。
有些结构同时引入类型(在运行时不可用)和值(在运行时不可用)。比方说class关键字,就是其中的一个。创建类 Square 和 Rectangle 是另一种避免报错的方法:
class Square {
constructor(public width: number) {}
}
class Rectangle extends Square {
constructor(public width: number, public height: number) {
super(width);
}
}
type Shape = Square | Rectangle;
function calculateArea(shape: Shape) {
if (shape instanceof Rectangle) {
shape; // Type is Rectangle
return shape.width * shape.height;
} else {
shape; // Type is Square
return shape.width * shape.width; // OK
}
}
这是因为 Rectangle 有类型也有值,而接口只有类型。
Rectangle 在 type Shape = Square | Rectangle 中指的是类型,但是 Rectangle 在 shape instanceof Rectangle 中指的是值。理解这个区别很重要,但可能会很不易察觉。(详见第8小节)
类型的操作不会影响到运行时的值
假设有一个值可能是string也可能是number,你想要规范它,让它始终是一个number类型。下面是类型检测器接受的一个错误尝试:
function asNumber(val: number | string): number {
return val as number;
}
我们来看一下上面的TS代码生成JS之后真正的样子:
function asNumber(val) {
return val;
}
根本没有任何转化,断言语句 as number 只是类型操作,因此并不会影响代码的运行时行为。为了让值正常,你需要检查运行时的类型,并且通过JS来实现:
function asNumber(val: number | string): number {
return typeof(val) === 'string' ? Number(val) : val;
}
(as number 是类型断言,关于更多的更加恰当的使用方法,见第9节)
运行时类型有可能跟申明的类型不一致
下面的函数最后能够打印出正确的信息吗?
function setLightSwitch(value: boolean) {
switch (value) {
case true:
turnLightOn();
break;
case false:
turnLightOff();
break;
default:
console.log(`I'm afraid I can't do that.`);
}
}
TS 通常会标记出死代码,但不会处理它,即使是在严格配置下也一样。那么,怎么进入最后的这个条件分支呢?
关键是要记得 boolean 只是一个声明类型。因为它是一个TS类型,在运行时状态下,它就没了。在JS代码中,使用者可能会无意间用"ON"一样的值来调用函数 LightSwitch。
在纯TS中也有触发上面代码的方法。有可能这个函数被一个网络请求返回的值调用:
interface LightApiResponse {
lightSwitchValue: boolean;
}
async function setLight() {
const response = await fetch('/light');
const result: LightApiResponse = await response.json();
setLightSwitch(result.lightSwitchValue);
}
你已经声明了请求返回的类型是 LightApiResponse ,但是这并不是强制性的。如果你将 API 的返回结果 和 lightSwitchValue 的错误的理解成string,那么在运行时的时候就会传一个string给到 setLightSwitch 方法。也有可能 API 在代理的过程中被转换了。
当运行时类型跟声明的类型不匹配时,TS是让人很困惑的。这种情况,也是你要尽可能去避免的。但是请注意,一个值的类型在你声明的类型之外还有其它可能。
不能基于TS类型来重载函数
像C++这样的语言允许你定义一个函数的多个版本,这些版本只在参数类型上有所不同。这就叫做函数的重载。因为函数的运行时行为是独立于TS类型,下面的结构在TS中是不可以的:
function add(a: number, b: number) { return a + b; }
// ~~~ Duplicate function implementation
function add(a: string, b: string) { return a + b; }
// ~~~ Duplicate function implementation
TS 没有提供函数重载的基础能力,但是它可以在类型层面进行操作。你可以为一个函数提供多个声明,但是只有一个会执行。
function add(a: number, b: number): number;
function add(a: string, b: string): string;
function add(a, b) {
return a + b;
}
const three = add(1, 2); // Type is number
const twelve = add('1', '2'); // Type is string
add 的前两个声明只提供类型信息。当TS生成JS的时候,它们会被移除,只剩下可以执行的部分。(如果你想使用这种风格的重载,请先看看第50小节,还有一些细节需要你注意的)
TS 类型对运行时性能没有影响
因为类型跟类型操作在生成JS的时候就被消除了,它们不会影响到运行时的性能。TS的静态类型是真正意义上的零开销。下次有人把运行时开销作为不使用TS的理由时,你就能确切的知道他们并没有做过测试!
对此有两个警告:
- 虽然没有运行时开销,但是TS编译器会产生构建时的开销。TS 团队非常重视编译器的性能,编译通常非常快,尤其对于增量构建。如果开销变得很重要,你的构建工具可能有一个“Transfile only”选项来跳过类型检查。
- 相较于原生实现,TS为了支持旧的运行时的代码可能会带来性能开销。比如,如果你使用生成器功能生成早于生成器的ES5,tsc要通过一些辅助的代码来保证功能正常。相比于原生生成器的实现,这样可能会有一些额外开销。 在任何情况下,这都取决于目标和语言等级,并且还是跟类型无关。
应该记住的点
- 代码生成跟类型系统无关。意思就是说TS的类型不会影响到运行时行为或者代码的性能。
- 程序就算有类型错误但还是产生代码(编译)也是有可能的。
- TS类型在运行时不可用。如果在运行时想查询一个类型,需要某种方法来重建它。标记联合跟属性检查是常用的手法。一些结构,比如类 class,可以同时引入在运行时可用的TS类型和值。