TypeScript 编译器能捕捉到而 JavaScript 却会漏过的错误
并列对比 JavaScript 与 TypeScript:类型推断、注解、原始类型、any、联合类型及带类型的函数,还有 tsc 实际会标记和输出的内容。
JavaScript几乎可以执行任何给定的代码,这也正是某些错误只有在实际运行环境中才会显现的原因。TypeScript在代码执行前增加了检查步骤,但人们很容易误解这一步骤的作用以及它无法提供哪些保护。本指南通过从变量重赋值到带类型函数的简单示例,帮助你清晰地了解JavaScript在哪些情况下保持沉默、TypeScript编译器在何时发挥作用,以及如何配置它以便让错误真正阻止构建过程。
动态类型与静态类型
JavaScript属于动态类型语言。变量没有固定的类型;其类型由当前存储的值决定,在程序运行期间会发生变化,且随时可能改变。
TypeScript 是 JavaScript 的静态类型超集。所有有效的 JavaScript 程序在语法上也是有效的 TypeScript 代码,但 TypeScript 增加了类型检查器,可在任何代码执行之前于编译时分析代码。随后该检查器会移除类型信息,输出纯 JavaScript 代码。
本指南中的其他内容均源于这一差异。在运行时决定类型能让 JavaScript 更灵活,但也可能让错误未被发现;而提前确定类型则使 TypeScript 更严格,能及早暴露许多错误,这也就是人们称 TypeScript 为类型安全语言的原因。严格来说,这种安全性只是编译时的保证:它仅在检查器能够覆盖的范围内有效。
变量类型何时会改变
在纯 JavaScript 中,可以先定义一个数字变量,然后使用 typeof 来检查其类型:
let age = 30;
console.log(typeof(age)); // output is number
几行代码之后,你完全可以将字符串存储在同一个变量中:
age = "John";
console.log(age);
console.log(typeof(age)); // string
JavaScript会接受这种操作而不会给出警告或错误。第一次使用typeof时会显示"number",第二次则显示"string":同一个变量在其生命周期内拥有了两种完全不同的类型。在规模较小的、无害的脚本中这倒没什么问题;但在大型代码库中,任何仍期望从age获取数字值的代码都可能出现异常,而且错误表现可能与引发问题的那行代码相距甚远。
TypeScript则不允许这样的代码:
let data = 2;
data = "two"; // ❌ Error: Type 'string' is not assignable to type 'number'
当 TypeScript 看到 let data = 2; 时,它会根据初始值推断出 data 的类型为 number,并且在整个变量生命周期内都保持该类型。这种行为被称为类型推断。因此,在程序运行之前,编译器就会拒绝下一行中 "two" 的赋值操作。
除了依赖类型推断,你也可以自行指定类型:
let data: number = 2;
: number 这种写法是 TypeScript 独有的语法,在 JavaScript 中并不存在。从类型安全的角度来看,这两种写法是等价的;添加注释的版本只是让日后阅读代码的人(包括六个月后的你自己)更清楚地了解代码的意图而已。
需要记住的规则是:JavaScript 允许变量随时改变类型,而 TypeScript 会通过注释或变量的初始值确定其类型,并从此强制保持该类型。
字符串与数字的陷阱
前面的例子展示了 TypeScript 会进行类型检查,接下来的例子则说明了这一点为何重要。
JavaScript 同样允许原本存储字符串的变量被数字覆盖:
let message = "Hello";
let num = 2;
message = 20; // totally allowed in JS
现在出现经典的意外情况:
let result = "5" + 3;
console.log(result); // "53", not 8
你可能会想到8,因为"5"看起来像数字。但实际上"5"是字符串,当+ 运算符遇到两侧都是字符串的情况时,JavaScript会执行字符串连接而非算术运算。它会将3转换为文本并与之拼接,最终得到"53"。JavaScript会默默执行这一操作,因为它从不提前检查类型,只是按照代码在运行时的实际内容来处理。
TypeScript对相同代码的处理方式
以下是明确了类型的TypeScript版本:
let num1: string = "5";
let num2: number = 3;
而加法运算本身的结果则是:
let add = num1 + num2; // expected 8, but num1 is a string and num2 is a number
console.log(add); // actual result is 53
打印出的值仍然是53。很多解释都在这里出错,因此有必要做到准确无误。TypeScript刻意允许string + number这种操作,因为将文本与数字拼接是JavaScript中常见且合法的用法。检查工具不会拒绝该表达式,它会推断add的类型为string。
你可能见过的运算符错误如下所示:
Operator '+' cannot be applied to types 'string' and 'number'.
TypeScript会对那些它认为没有意义的操作数组合显示此类消息,例如将boolean与number相加,或是将类型完全不同的值相加。这并非上面num1 + num2的情况。
在这种情况中,TypeScript 能保护你的地方在于结果被用作数字的那一刻。如果你写下 let add: number = num1 + num2;,编译器会提示 string 类型无法赋值给 number 类型。同样,将 add 传递给期望接收 number 的函数也会失败。实际上,“5” + 3 这类错误在类型化代码库中正是通过这种方式被发现的:不是在加号处,而是在字符串意外出现在本应接收数字的代码位置时。
为何错误默认不会终止程序
还有第二个细微之处。即便 tsc 确实发现了类型错误,它默认仍会生成编译后的 .js 文件。用 node 运行该文件时,执行的是普通的 JS 代码而已。所有的类型信息都已被清除,仅剩下运行时的逻辑。
这就是 TypeScript 的精髓:它不会改变代码在运行时的行为。它会在你运行任何程序之前,在编辑器和编译阶段就指出问题。如果你希望一旦出现类型错误,编译器就拒绝生成输出,可以在 tsconfig.json 中启用此选项:
{
"compilerOptions": {
"noEmitOnError": true
}
}
开启 noEmitOnError 后,含有类型错误的代码永远无法到达 node,因为 tsc 会在不生成 JavaScript 文件的情况下停止运行。在大多数实际项目中,可以通过在 CI 流水线中单独执行 tsc --noEmit 作为类型检查步骤,一旦出现错误就让构建失败,从而达到同样的效果。
关键点在于:TypeScript 的价值并不在于不同的运行时行为,因为编译后的 TypeScript 本质仍是 JavaScript。它的价值在于能在你点击运行之前发现不一致之处,让你有机会修复那些在普通 JavaScript 中会默默运行的错误。
注解与推断
这两个术语描述了达成相同结果的两种相反方式。以一个带注解的声明为例:
let age: number = 30;
逐项来看:
let是声明关键字age是变量名30是赋定的值number是类型: number,即冒号后跟类型,属于类型注解
注解通过: number、: string或: boolean这样的冒号语法,明确告知TypeScript某个变量的类型。而推断则相反:省略类型后,由TypeScript根据赋定的值来推导出该类型。
let age = 30; // inference — TS decides this is a "number" on its own
let city: string = "Delhi"; // annotation — you told TS explicitly
两者提供的保护效果相同,因为一旦类型由你或编译器设定后就会固定不变。注解主要起到提升清晰度的作用。当变量声明时未指定初始值且无法从中推断出任何信息时,注解最为有用;在函数签名和公共 API 中,注解则可用于说明其用途。对于那些初始值显而易见的局部变量,大多数团队会选择让系统自动推断。
原始类型与非原始类型
一种有助于分类早期会遇到的类型的办法是:
- 原始类型如
number、string、boolean、undefined和null,每种类型一次只能存储一个值。
原始类型是一个仅能存放一个项目的容器;而结构化类型则是一个可容纳多个容器的箱子。
在初学者的学习内容中,通常还会提到与原始类型并列的另外两种结构,下文将对此进行说明:any和联合类型。严格来说,它们都属于非原始类型。any是一种用于绕过检查的机制,而联合类型则是组合其他类型的一种方式。它们之所以与原始类型一同提及,是因为它们通常用于处理单个值。
实际应用中的原始类型
数字
TypeScript中的number类型既包括整数也包括浮点数。与其他许多语言不同,这里没有独立的int或float类型,因为JavaScript本身会将普通数字以64位浮点数的形式存储。
let age = 20;
let price = 26.2;
let big = 24757689;
打印这些数值并使用typeof进行类型检查:
console.log("Age :", age);
console.log("price:", price);
console.log(big);
console.log(typeof(age)); // "number"
整数、小数以及大数值都属于number类型,typeof操作会对它们分别返回“number”。对于超出该浮点数格式表示范围的整数,JavaScript提供了独立的bigint类型。
字符串
字符串是由字符组成的序列:可以是单词、句子或任何文本。你可以使用单引号、双引号或反引号来书写字符串字面量。
let firstname: string = 'JOHN';
let lastname: string = "SINGH";
console.log("Hello", firstname, lastname); // Hello JOHN SINGH
将多个用逗号分隔的参数传递给console.log时,它们会以空格分隔的形式被输出,这就是为什么这里能输出全名的原因。不过,有一行代码的实际效果并非看上去那样:
// let greeting: string = "Hello", firstname, lastname; // ❌ this does NOT concatenate
在let语句中,逗号用于分隔多个声明,而非连接文本。这行代码声明了三个独立的变量:greeting、firstname和lastname,但只有greeting被赋予了值。如果firstname和lastname已存在于同一作用域中,TypeScript也会报错称它们被重新声明了。
因此,逗号适用于输出多个值,而若要将组合后的句子存储在变量中,则应使用模板字面量:即用反引号包裹的字符串,其中${}用于放置表达式。
let firstname: string = 'JOHN';
let lastname: string = "SINGH";
let greeting: string = `Hello ${firstname} ${lastname}`;
console.log(greeting); // Hello JOHN SINGH
模板字面量是混合文本与值的推荐方式。相比使用+进行字符串连接,它的代码更简洁,同时还能支持跨多行的字符串。
布尔值
布尔值仅有两种可能的状态:true和false。
let isStudent: boolean = true;
let hasJob: boolean = false;
输出该值并检查其类型:
console.log("Is student?", isStudent); // true
console.log(typeof (isStudent)); // "boolean"
布尔值决定了代码中的所有条件判断:if语句、循环条件以及任何需要做出是或否决策的场景。
空值与未定义值
null和undefined都表示没有值,但它们的含义不同。可以先从一个被声明但从未赋值的变量开始了解:
let emptyvalue;
console.log(typeof emptyvalue); // "undefined"
被声明但未赋值的变量其值为undefined,这意味着该变量存在,但目前还没有存储任何内容。
你也可以明确为变量赋值 null 和 undefined 类型:
let emptyvalue: null = null;
let value: undefined = undefined
然后比较它们各自的输出结果,以及未赋值的变量类型:
console.log(emptyvalue); // null
console.log(value); // undefined
let price: number;
console.log(price); // undefined - declared, but not yet assigned any value
用通俗的话来说,两者的区别是:
undefined表示变量存在但尚未被赋予值;它是默认的空状态。null表示开发者故意将变量设置为“无值”;这是一种有意的、明确的空状态。
像 : number 这样的类型注解仅限制变量后续可以存储的数值类型,并不会为变量提供初始值。因此在运行时,price 会输出 undefined。不过需要注意的是,在现代项目中通常启用的 strict 模式下,TypeScript 会在 console.log(price) 这行代码处报错,因为该变量在赋值前就被使用了。这一检查正是编译器能够发现普通 JavaScript 会忽略的错误的又一例证。
在日常代码中,很少会将变量直接注解为 null 或 undefined;这些示例的存在只是为了展示这两种空值状态的行为差异。更为常见的是使用如 string | null 这样的联合类型来表示可能缺失的值。
any 类型:关闭检查机制
应谨慎且少用 any,因为它会关闭 TypeScript 所提供的那些检查机制。any 类型的变量可以接受各种类型的值,并且可以像普通 JavaScript 一样随意重新赋值为其他类型。
let value: any = "abcd";
console.log(typeof value); // "string"
先将其重新赋值为数字,然后再赋值为布尔值:
value = 123;
console.log(typeof value); // "number"
value = true;
console.log(typeof value); // "boolean"
由于注释告知编译器无需对其进行检查,该变量可以毫无问题地从字符串类型变为数字类型,再变为布尔类型。
它确实有合理的用途。例如,在无法提前知道数据类型时——比如面对不可预测的外部数据,或是从 JavaScript 进行增量迁移且无法一次性输入所有内容时,any 能让开发工作继续进行。然而,过度依赖它会导致自动补全功能失效、编译时错误出现,还会失去当初使用 TypeScript 的安全保障。应将其视为最后手段而非习惯性选择。对于形状未知的数据,unknown 通常是更好的选择,因为它能迫使你在使用数据前先进行检查。我们的指南 介绍如何用类型安全模式替代 any 对各种替代方案进行了深入探讨。
联合类型:受控的选项集合
联合类型允许一个值属于若干特定类型之一。与any不同,它并不接受所有类型,而只允许你列出的那些类型。“Union”并非关键字,而是该模式的名称,用竖线符号|表示。
let id: number | string | boolean;
依次为每种允许的类型赋值:
id = "123abc";
console.log(id);
id = 12;
console.log(id);
id = true;
console.log(id);
id变量可以存储数字、字符串或布尔值,除此之外不能存储其他类型。尝试赋值为如[1, 2, 3]这样的数组会立即被拒绝,因为数组不属于允许的类型范围。这种方式能在需要的地方提供灵活性——例如现实世界中的标识符有时以数字形式出现,有时则以字符串形式出现——同时又能明确界定允许的内容。在以特定类型方式使用联合类型值之前,TypeScript要求先对其进行限定,比如通过typeof检查。
函数类型定义
函数将单一任务封装在可重复使用的代码中。你只需定义一次,之后无论何时何地都需要使用时都可以调用它。
无参数函数
最简单的版本会将值直接硬编码进去:
function sum() {
console.log(10 + 20);
}
定义函数并不会立即执行它。只有在你调用它时才会发生相应动作:
sum(); // calling the function → prints 30
这种分离正是关键所在:只需编写一次逻辑,需要时再执行。
添加带类型的参数
一旦函数能够接受参数,即那些能让相同代码在每次调用时处理不同值的占位符,它们就真正具备了可重用性。
function sum(x: number, y: number) {
console.log(x + y);
}
使用两个数字来调用它:
sum(10, 20); // prints 30
现在 sum 已不再与 10 和 20 相关联;sum(5, 7) 或 sum(100, 1) 都能正常使用。每个参数后面的 : number 是对该参数的类型注解,因此 TypeScript 会在代码运行前拒绝类似 sum("hello", 20) 这样的调用。这正是 TypeScript 发挥作用的典型场景:在函数边界处,即程序的某一部分将数据传递给另一部分时。
这个版本仍然仅通过 console.log 打印结果,不会向调用者返回任何值,而这对于后续步骤来说很重要。
声明返回类型
能够返回结果的函数要实用得多:
function sum(x: number, y: number): number {
return x + y;
}
捕获并使用返回值:
let res: number = sum(10, 20);
console.log(res); // 30
与上一个版本相比,有三处变化:
return x + y;会将计算结果返回给调用者,而非在函数内部直接打印出来。- 在
(x: number, y: number): number中,参数列表后的: number即为返回类型注解。它表明该函数始终会返回一个数字,若返回字符串则会引发编译错误。 - 由于
sum现在会返回值,因此可以使用let res: number = sum(10, 20);将其存储起来以供后续使用,比如记录日志、传递给其他函数或与其他结果合并。
能够返回结果的函数可以与其他逻辑组合使用;而仅用于打印日志的函数则毫无用处,其输出无法被重复利用。TypeScript通常能够自动推断返回类型,但在导出的函数上显式写出返回类型可以避免意外改变函数的返回值。
总结
- JavaScript在运行时确定类型,并完全信任用户,这虽然灵活,但会导致诸如
"5" + 3产生"53"这样的隐蔽错误。 - TypeScript通过注解或推断为每个变量指定类型,并在代码运行前的编译阶段进行检查。
- TypeScript不会拒绝所有有问题的表达式;它允许字符串与数字拼接,只有在生成的字符串被用于期望接收数字的代码时才会进行验证。
- 默认情况下类型错误不会阻止输出;可在CI流程中使用
noEmitOnError或tsc --noEmit选项来让类型错误阻止构建过程。 - 只有在真正无法确定类型时才使用
any,为了灵活性则优先使用联合类型和unknown,并且在数据跨边界传递时为函数参数和返回类型添加注解。
JavaScript与TypeScript代码的运行时结果是完全相同的。不同之处在于出错时的发现位置:是在编辑器中,而非用户面前。