本节目标:搞清楚 TypeScript 为什么会出现、它究竟想解决什么问题,以及这些设计目标如何塑造了今天的语言行为。读完本节,你应该能用自己的话向同事解释「TypeScript 不是一门新语言,而是给 JavaScript 加的一层类型」,并且理解它有意不去做的那些事。
1.1 TypeScript 的诞生与设计目标
很多教程从 let x: number = 1 开始讲 TypeScript,这其实是个糟糕的开场——你会以为它只是某种新语言的语法糖。要真正理解它,得先回到 2010 年前后前端工程的那个具体处境。
1.1.1 一个具体的痛点
2010 年前后,浏览器里的 JavaScript 代码量第一次突破了几十万行量级。Gmail、Google Docs、Office Web Apps 这类产品把「网页」变成了「应用」,而它们的实现语言只有 JavaScript 一种选择。
问题随之而来:JavaScript 是动态类型语言,任何一次跨文件调用都建立在「我猜对方会返回什么」之上。看下面这段代码,它在开发、测试环境里都能正常跑:
// discount.js —— 这段代码在测试环境从没出过错
export function getDiscountRate(user) {
return user.membership.discountRate;
}
// checkout.js
import { getDiscountRate } from './discount.js';
const rate = getDiscountRate({ name: 'Ada', membership: null });
console.log(rate * 100);
只要有一个用户没有 membership,线上就会得到:
TypeError: Cannot read properties of null (reading 'discountRate')
这类错误的恼人之处在于:它完全合法。引擎、linter、代码评审都不会拦你,因为「user 有没有 membership」这件事根本没有被写在任何地方。它不是谁的疏忽,而是语言缺少表达「约束」的能力。
1.1.2 Anders Hejlsberg 的判断
2010 年,微软把 Anders Hejlsberg 请来主导一个新项目(他是 Turbo Pascal、Delphi、C# 的作者)。他面对的核心问题是:
能不能给 JavaScript 加一层可选的静态类型,让工具能在不运行代码的前提下发现这类错误,同时不破坏已有的 JS 生态?
2012 年 10 月,TypeScript 0.8 对外发布。注意这个年份——它不是「JS 太烂所以重写一门语言」的产物,而是一次工程折中:不改变运行时,只在源码之上加一层可检查、可擦除的类型信息。
当时市面上并不是没有别的尝试。理解它们为什么失败、TypeScript 为什么活下来,能让你更清楚这套折中的价值:
| 方案 | 做法 | 结局 |
|---|---|---|
| CoffeeScript | 发明一套更简洁的新语法,编译成 JS | 语法红利被 ES6 吸收,社区逐渐退出 |
| Dart | 造一门独立语言 + 自己的虚拟机 | 浏览器端未能普及,转向前端框架与移动端 |
| Flow | 给 JS 加类型,用注释式语法 | 目标与 TS 最接近,但工具链与生态落后 |
| TypeScript | 严格超集 + 可擦除类型 + 完整工具链 | 成为事实标准 |
四条路线里,Dart 试图替换 JavaScript 的运行时,CoffeeScript 试图替换它的语法,Flow 与 TypeScript 都在 JS 上加类型。分水岭在于:TypeScript 坚持不改变运行时,且把「类型能描述既有 JS 习惯」放在「类型系统理论优美」之前。 一个 .js 文件改名就能用,这是它赢下生态的根本原因。
1.1.3 时间线:那些决定方向的版本
| 版本 | 时间 | 关键变化 |
|---|---|---|
| 0.8 | 2012-10 | 首次公开发布,引入 module / class 语法 |
| 1.0 | 2014-04 | 正式版,tsc 稳定,编辑器支持成形 |
| 1.5 | 2015-07 | 模块、let / const、装饰器雏形 |
| 2.0 | 2016-09 | strictNullChecks 可开启,never、判别联合成熟 |
| 2.3 | 2017-04 | --strict 开关,@types 生态成形 |
| 3.0 | 2018-07 | Project References,unknown 成为真正的顶层类型 |
| 4.1 | 2020-11 | 模板字面量类型,类型层能力大幅扩张 |
| 4.9 | 2022-11 | satisfies 运算符 |
| 5.0 | 2023-03 | 标准装饰器(Stage 3)、const 类型参数 |
| 5.5 | 2024-06 | 类型推断能力增强、isolatedDeclarations 推进 |
这张表里有两处值得停下来看:2.0 的 strictNullChecks 和 3.0 的 unknown。它们说明 TypeScript 的设计不是一次性完成的,而是逐步收紧:先让你能上手(所有类型都允许 null),再给你一个开关去消除空值错误。这条「渐进」路线会贯穿全书。
1.1.4 十一条设计目标
官方 TypeScript Design Goals 列出十一项目标,并另外列出非目标。下面按官方顺序意译;「渐进式采用」是工程特点,不是第十项目标:
| # | 设计目标(意译) | 你会在哪里感受到它 |
|---|---|---|
| 1 | 静态识别可能出错的结构 | 编译期报错,而不是线上崩溃 |
| 2 | 为大型应用提供代码组织机制 | module、import type、Project References |
| 3 | 不给输出程序增加类型检查的运行时开销 | 类型注解不变成运行时验证 |
| 4 | 输出清晰、惯用、可识别的 JS | 降级语法时仍可能生成辅助函数 |
| 5 | 语言可组合、易于推理 | 小类型和函数可以组合 |
| 6 | 对齐当前及未来 ECMAScript 提案 | 支持标准中的新语法 |
| 7 | 保留 JS 代码的运行时行为 | 加类型不改变原有运算规则 |
| 8 | 避免新增表达式层语法 | 尽量减少 TS 专有的运行时语法 |
| 9 | 使用一致、可擦除的结构化类型系统 | interface 和类型注解被擦除 |
| 10 | 成为跨平台开发工具 | 编译器不依赖特定操作系统 |
| 11 | 避免相对 TypeScript 1.0 的重大破坏 | 重视已有程序的兼容性 |
目标 9 的可擦除类型,以及非目标中的「不依赖运行时类型信息、不提供额外运行库」,共同解释了运行时边界。下面把这些原则和渐进式采用联系起来。
1.1.5 目标如何变成今天的语言行为
目标 1「静态识别错误」→ 类型检查。 回到开头那段 getDiscountRate,给它加上类型:
interface User {
name: string;
membership?: { discountRate: number };
}
function getDiscountRate(user: User): number {
return user.membership?.discountRate ?? 0;
}
如果你偷懒写成 user.membership.discountRate,tsc 会立刻告诉你:
error TS18048: 'user.membership' is possibly 'undefined'.
在 TypeScript 4.8 之前,同样的错误编号是 TS2532。老教程里见到 TS2532 不用困惑,它们检查的是同一件事。注意这里的关键差别:错误从运行时提前到了编译期,而且不需要你构造出那个「刚好没有 membership 的用户」。
目标 9「可擦除的类型系统」→ 类型擦除。 上面那段 interface 编译成 JavaScript 后,一行都不剩:
function getDiscountRate(user) {
return user.membership?.discountRate ?? 0;
}
interface、类型注解、? 全部消失。这解释了很多新手困惑:为什么不能在运行时 typeof 一个 interface?为什么 instanceof 不能用在类型别名上?——因为它们在运行时不存在。这是第 1.2 节的主题。
渐进式采用 → 有意识地保留迁移入口。 TypeScript 从设计上就允许你「不完美地」用它:
// 1. any:彻底放弃这个值的类型检查
function loadFromOldSystem(): unknown { return { name: "Ada" }; }
let legacy: any = loadFromOldSystem();
// legacy.whatever().noProblem(); // 编译通过,但运行时会抛 TypeError
// 2. @ts-expect-error:显式承认这行有问题,但暂时保留
// @ts-expect-error 演示:暂时允许一处已知的类型不匹配
const migrationValue: number = "legacy";
// 3. 逐文件迁移:allowJs 与 checkJs 的组合
{
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"strict": false
}
}
正因为有这些口子,一个 50 万行的老项目才可能今天就开始用 TypeScript,而不是「等有空了整体重写」。这也解释了为什么官方一直不把 any 从语言里删掉——它是渐进策略的一部分,而不是设计缺陷。
目标 4「输出干净 JS」→ 只有装饰器会引入辅助代码。 大多数 TS 语法编译后是「零成本」的,但装饰器需要运行时支持,tsc 会注入 __decorate 之类的 helper。这也是为什么前端项目更愿意用 esbuild、SWC 来做转换(见 前端 esbuild 原理
),而把 tsc 留给类型检查。
1.1.6 三个常见误区
| 误区 | 事实 |
|---|---|
| 「TypeScript 是一门新语言」 | 它是 JS 的超集,去掉类型注解就是 JS |
| 「用了 TS 就不会有运行时错误」 | 类型只在编译期,any、外部数据、类型断言都能绕过它 |
| 「TS 会拖慢运行速度」 | 类型被擦除,产物与手写 JS 基本等价;慢的只是构建与检查环节 |
第二条尤其重要。第 13 章会专门讨论「类型擦除带来的运行时盲区」以及如何用 Zod 之类的方案补上。现在你只需要记住:类型是编译期的契约,不是运行时的保证。
1.1.7 为什么是「设计目标」而不是「特性列表」
读完这一节,希望你建立这样一个习惯:遇到 TypeScript 的某个「反直觉」行为时,先问「这对应哪条设计目标」。
- 为什么
let x: number = "a"报错,但JSON.parse('"a"')返回any不报错?——目标 7 要保留 JS 的运行时行为;非目标也明确不追求完全可靠的类型系统,any 是兼容现有动态代码的入口。 - 为什么编译产物里看不到
interface?——目标 9,可擦除的结构化类型。 - 为什么
tsc既能检查又能把代码降级到 ES5?——体现了跨平台工具的定位(目标 10);具体降级行为由 target 决定。
这套「目标 → 行为」的映射,比背语法有用得多。当你后面学到 satisfies、const 类型参数、verbatimModuleSyntax 这些进阶特性时,回头对照这张表,会发现它们几乎都能归到某条目标之下。
1.1.8 从设计目标反推能力边界
把设计目标与非目标结合起来读,可以理解 TypeScript 的能力边界。它做不到的事,往往不是「还没实现」,而是「被设计排除了」:
| 你可能期望的能力 | TypeScript 为什么不提供 |
|---|---|
| 运行时判断一个值是否符合某接口 | 类型被擦除,运行时没有类型信息(目标 9、非目标 5) |
| 用类型自动校验接口返回的数据 | 类型是编译期声明,不产生校验逻辑(目标 9、非目标 5) |
| 通过类型提升代码执行速度 | 类型注解不会触发 JIT 优化,编译器也不追求主动优化运行速度(非目标 2) |
| 精确描述所有 JS 运行时的动态行为 | 需要在正确性、生产力与 JS 兼容性间折中(非目标 3) |
一个具体例子。很多人第一次看到下面这段会疑惑:
interface Point { x: number; y: number }
const p: Point = JSON.parse('{"x":1,"y":2}'); // 编译通过
console.log(p.x.toFixed(2)); // 运行时才可能崩
JSON.parse 返回 any,any 能赋给 Point,于是编译器接受了这个类型注解。这里有类型注解,却没有运行时验证;any 绕开了检查,目标 9 与非目标 5 则解释了为什么类型声明不会生成校验器。 想补上这道防线,需要引入运行时校验(第 13 章),让类型与校验逻辑来自同一个定义。
1.1.9 strict 家族:设计目标的开关化
「渐进式」目标还有一个直接体现:TypeScript 把大部分严格检查做成了独立开关,你可以按需开启。strict: true 只是这些开关的集合。
{
"compilerOptions": {
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true
}
}
其中对初学者影响最大的是两个:
strictNullChecks:null与undefined不再默默兼容所有类型。开启后,第 1.1.1 节那个线上事故会在编译期被拦下。noImplicitAny:参数没有注解且无法推断时直接报错,而不是悄悄当成any。
// strictNullChecks: false 时通过;true 时 str 的类型会收窄为 string,不再兼容 null
function shout(str: string | null): string {
if (str === null) return '';
return str.toUpperCase(); // 收窄之后才允许调用
}
shout(null); // 显式传入 null,类型系统要求调用方处理
新项目建议一开始就 strict: true;老项目则按 strictNullChecks → noImplicitAny → 其余 的顺序逐项开启,每一步修完再开下一项。完整的开关清单与迁移顺序在《TypeScript编程入门》16.1 编译目标与严格模式配置
里系统展开。
1.1.10 学完本节你应该能回答的问题
在进入下一节之前,用下面几个问题自测,答不上来就回看对应小节:
- TypeScript 诞生于哪一年、由谁主导、最初的痛点是什么?(1.1.1、1.1.2)
- 哪些目标和非目标最能解释「类型在运行时不存在」?(1.1.4)
- 为什么官方一直不删掉
any?(1.1.5) - 为什么编译产物里看不到
interface,却能看到enum?(1.1.5,下一节详述) - 「用了 TypeScript 就不会有运行时错误」这句话错在哪?(1.1.6)
把这些问题的答案串起来,你就拥有了一套稳定的心智模型:TypeScript 是在 JavaScript 之上做静态分析的工具,它的全部价值发生在编译期,它的全部限制也来自这一点。
小结
- TypeScript 诞生于「大型 JavaScript 应用难以维护」这一具体工程痛点,2012 年由 Anders Hejlsberg 主导发布。
- 它的核心定位是在 JavaScript 之上加一层可选、可擦除的静态类型,而不是发明新语言或新运行时。
- 官方目标强调可擦除的结构化类型,非目标明确不依赖运行时类型信息;渐进式采用则体现在
any与逐文件迁移等工程机制里。 - 用「目标 → 行为」的方式理解语言决策,比死记语法更有效。
下一节我们把「超集」和「类型擦除」这两件事讲透:你会亲手看到一段 TypeScript 编译前后到底发生了什么变化,以及由此带来的运行时限制。如果你已经等不及想动手,可以直接跳到《TypeScript编程入门》2.1 安装 Node.js、TS 与编辑器配置 。
阅读导航:下一节:1.2 与 JavaScript 的关系(超集·类型擦除) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。