《TypeScript编程入门》1.1 TypeScript 的诞生与设计目标

本节从 2010 年前后大型 JavaScript 应用的维护困境讲起,梳理 TypeScript 由 Anders Hejlsberg 主导诞生的真实动机,并逐条拆解官方的十一条设计目标。你会看到「不引入新运行时」「渐进式采用」如何被翻译成今天的语言行为,理解为什么类型在运行时并不存在。读完本节,你能讲清 TypeScript 解决什么问题、又刻意不解决什么,为后续编码建立正确的心智模型。

本节目标:搞清楚 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.82012-10首次公开发布,引入 module / class 语法
1.02014-04正式版,tsc 稳定,编辑器支持成形
1.52015-07模块、let / const、装饰器雏形
2.02016-09strictNullChecks 可开启,never、判别联合成熟
2.32017-04--strict 开关,@types 生态成形
3.02018-07Project References,unknown 成为真正的顶层类型
4.12020-11模板字面量类型,类型层能力大幅扩张
4.92022-11satisfies 运算符
5.02023-03标准装饰器(Stage 3)、const 类型参数
5.52024-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 的关系(超集·类型擦除) 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes