《TypeScript编程入门》1.2 与 JavaScript 的关系(超集·类型擦除)

本节回答一个新手最容易搞混的问题:TypeScript 和 JavaScript 到底是什么关系。我们用一张集合图讲清「超集」的含义,再通过一次完整的编译对照,让你亲眼看到类型注解、interface、type 别名在产物里如何被整行抹掉,而 enum 与装饰器又为什么是例外。最后归纳类型擦除留下的三个运行时盲区,并给出对应做法。读完本节,你能准确判断哪些信息在运行时还活着、哪些只属于编译期。

本节目标:彻底弄懂 TypeScript 与 JavaScript 的关系——为什么说它是「超集」,以及「类型擦除」到底擦掉了什么、留下了什么。读完本节,你应该能在看到任意一段 TS 代码时,准确说出它编译后长什么样,并知道哪些类型信息在运行时已经不存在了。

1.2 与 JavaScript 的关系(超集·类型擦除)

上一节我们反复提到两个词:「超集」和「类型擦除」。它们是 TypeScript 全部「好用」与全部「坑」的共同源头。这一节我们把它们彻底讲清楚。

1.2.1 超集:一张集合图

「TypeScript 是 JavaScript 的超集」这句话的准确含义是:所有合法的 JavaScript 程序,都是合法的 TypeScript 程序。

用集合表示就是:

概念说明例子
JavaScript 子集完全合法的 JS,同时也是合法的 TSconst n = 1 + 2;
TypeScript 独有语法只有 TS 能写,编译后被抹掉const n: number = 1;
两者交集(日常写法)你写的绝大多数代码其实都落在这里function add(a, b) { return a + b; }

这个性质带来一个非常实用的结果:把一个 .js 文件直接改名成 .ts,绝大多数情况下它立刻就能通过编译。 这正是渐进式迁移(第 18 章)的技术基础。

不过「超集」有两个需要知道的例外,否则你会在迁移时莫名其妙地卡住:

  • 少数合法 JS 会被 TS 拒绝:例如函数有重复形参 function f(a, a) {}、把保留字当标识符使用、在严格模式下给未声明变量赋值等。这些写法在 TS 里会直接报错,需要改代码。
  • --strict 会额外新增检查:改名成 .ts 能通过编译,不代表能通过 strict 下的检查。空值、隐式 any 这类错误会成批冒出来——这也是为什么迁移要分阶段(先 strict: false,再逐项收紧)。

1.2.2 编译器不是解释器

理解 TypeScript 的一个关键认知:它从来不会去执行你的代码。 .ts 文件必须先用 tsc(或其他工具)编译成 .js,再由 Node.js 或浏览器运行。

下面这条命令把 src/index.ts 编译到 dist/ 下,目标是 ES2020 模块:

npx tsc src/index.ts --outDir dist --target es2020 --module esnext

这条命令只做两件事:类型检查 与 语法降级。它不会做类型校验之外的运行时防护,也不会在产物里留下任何类型痕迹。类型检查的完整配置在《TypeScript编程入门》2.2 tsc 与 tsconfig.json 初探 里展开,本节先看产物。

1.2.3 类型擦除:逐行对照

下面是一段刻意用满了各种类型语法的源文件:

// src/account.ts
interface User {
  id: number;
  name: string;
}

type Role = 'admin' | 'member';

export function describe(user: User, role: Role): string {
  const label: string = role === 'admin' ? '管理员' : '成员';
  return `${user.name}(${label})`;
}

用 tsc 编译后,dist/account.js 长这样:

// dist/account.js —— 注意 interface / type / 类型注解全部消失
export function describe(user, role) {
    const label = role === 'admin' ? '管理员' : '成员';
    return `${user.name}(${label})`;
}

逐项对照一下被擦除了什么:

源码里的东西编译后原因
interface User { ... }整块消失只在编译期描述结构
type Role = 'admin' | 'member'整行消失类型别名不产生运行时值
: User、: Role、: string消失参数与返回值注解
: string 局部变量注解消失变量注解
模板字符串、===原样保留这些是 JS 本身的能力

结论:类型注解是「注释」的加强版。 它们对编译器有意义,对运行时完全没有。这也解释了第 1.1 节的那个问题:为什么不能在运行时判断一个值是不是 User?因为 User 在运行时根本不存在。

1.2.4 例外:会留下运行时代码的语法

「类型全部被擦除」这句话有个重要例外:少数 TS 语法在编译后会生成真实的 JavaScript 代码。 最典型的是 enum。

// src/level.ts
export enum Level {
  Low,
  High,
}

编译产物(--target es2020)是:

export var Level;
(function (Level) {
    Level[Level["Low"] = 0] = "Low";
    Level[Level["High"] = 1] = "High";
})(Level || (Level = {}));

enum 变成了一个真实的对象,还带双向映射(Level.Low === 0 且 Level[0] === "Low")。同类的还有:

语法是否产生运行时代码说明
enum是生成对象 + 反向映射
const enum否(内联)编译期替换成字面量,但受 isolatedModules 限制
装饰器是注入 __decorate 等 helper
class是但那是 JS 自己的类,不算 TS 特性
参数属性 constructor(private x: number)是会生成赋值语句
namespace是生成 IIFE
其余绝大多数类型语法否纯编译期

这个表格解释了一个工程实践:在只做类型检查、由 Babel/esbuild 负责转译的管线里,enum 和 namespace 经常被禁用,因为转译工具未必能正确处理它们的运行时代码。第 16 章讨论构建链时会再遇到这一点。

1.2.5 擦除留下的三个「洞」

类型擦除不是免费的,它留下三个必须在工程中正面处理的洞。

洞一:运行时的类型信息是残缺的。 typeof 和 instanceof 依然可用,但它们只能作用于值,且能力有限:

const value: unknown = 'hello';

console.log(typeof value);      // "string"  —— 可以,但只能得到原始类型
console.log(value instanceof String); // false —— 原始值不是 String 对象

typeof 分不清 Array 与普通对象(都是 "object"),也分不清 interface 的两种实现。想做可靠的运行时判别,得靠类型守卫(第 7 章)。

洞二:编译期类型拦不住运行时的数据。 这是最危险的一个。下面这段代码完全通过类型检查:

interface ApiUser {
  id: number;
  name: string;
}

async function loadUser(): Promise<ApiUser> {
  const res = await fetch('/api/user/1');
  return res.json(); // json() 的返回类型是 Promise<any>,赋给 ApiUser 不报错
}

async function main(): Promise<void> {
  const user = await loadUser();
  console.log(user.name.toUpperCase()); // 若接口返回 { name: null },这里崩溃
}
// 在提供 /api/user/1 的浏览器页面中执行:void main();

res.json() 返回 any,any 可以赋给任何类型——编译器于是「相信」了你的声明。这是类型断言与 any 带来的系统性风险,第 13 章会给出用 Zod 做运行时校验 的完整方案。记住一句话:边界处的数据必须校验,不能靠类型断言。

洞三:只用于类型的 import 可能带来歧义。 下面这行,User 只在类型位置被用到:

import { User } from './types.js';

export function find(id: number): User | undefined { return undefined; }

在默认配置下,编译器知道 User 是纯类型,会把整条 import 删掉,产物更干净。开启 verbatimModuleSyntax 后,类型导入必须写 import type;若 User 只是类型,原写法会触发 TS1484。单文件转译器可能保留未标记的导入,运行时会尝试加载该模块;isolatedModules 本身并不等于保留所有导入。正确写法是明确表达意图:

import type { User } from './types.js'; // 只导入类型,编译后必定消失

在 ESM/CJS 互操作里,这个区别会直接决定程序能否启动,详见《TypeScript编程入门》11.2 ESM/CJS 互操作与 moduleResolution 与 TypeScript 模块解析实战 。

1.2.6 一个能跑的完整例子

把上面的知识点串起来,做一个小实验:写一个 .ts 文件,编译,然后运行产物。

// src/index.ts
interface Config {
  host: string;
  port: number;
}

const config: Config = { host: 'localhost', port: 3000 };

function printAddress(c: Config): void {
  console.log(`${c.host}:${c.port}`);
}

printAddress(config);
npx tsc src/index.ts --outDir dist --target es2020 --module esnext
node dist/index.js

真实输出:

localhost:3000

现在打开 dist/index.js,你会看到 interface Config 和两处 : Config、一处 : void 都不见了,只剩三行普通 JavaScript。亲手做一遍这个实验,比读十遍「类型擦除」的定义都管用。

1.2.7 一句话记住两者关系

视角说法
语法上TypeScript = JavaScript + 类型语法(超集)
运行时只有 JavaScript。TS 的类型在这一层不存在
工具链上tsc 是「检查器 + 降级器」,不是运行环境
心智上把类型当成「编译期契约」,别指望它保护运行时

1.2.8 擦除之后:如何补回运行时信息

既然类型在运行时不存在,遇到「必须知道真实结构」的场景该怎么办?答案不是「放弃类型」,而是让类型与运行时校验共用同一份定义。最常用的做法是引入 schema 库:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
});

// 从 schema 推导类型,而不是手写两遍
type User = z.infer<typeof UserSchema>;

function parseUser(raw: unknown): User {
  return UserSchema.parse(raw); // 运行时会真的检查,失败则抛错
}

const ok = parseUser({ id: 1, name: 'Ada' });          // ✅
const bad = parseUser({ id: 'x', name: 'Ada' });       // ❌ 运行时抛 ZodError

这段代码的价值在于:类型与校验逻辑同源。改了 schema,类型自动跟着变,不会出现「声明说是 number、实际校验的是 string」的漂移。第 13 章会详细展开,先看一眼 运行时校验与类型安全 。

如果不想引入依赖,也可以用类型守卫手写:

interface User { id: number; name: string }

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    typeof (value as User).id === 'number' &&
    typeof (value as User).name === 'string'
  );
}

const data: unknown = JSON.parse('{"id":1,"name":"Ada"}');
if (isUser(data)) {
  data.name.toUpperCase(); // 这里 data 已被收窄为 User
}

两种做法的共同点:把「类型从哪来」这件事从编译期搬到运行时,因为擦除已经决定了编译期无法单独完成任务。这也是为什么「TypeScript 项目里一定有 Zod 或同类库」——它不是潮流,而是补洞的必需品。

1.2.9 小结前的自测

  • 一个 .js 文件改名为 .ts 后一定通过编译吗?有没有例外?(1.2.1)
  • interface、type、enum、装饰器,哪些会留下运行时代码?(1.2.4)
  • res.json() 为什么能赋给任意接口类型而不报错?(1.2.5)
  • import { User } 与 import type { User } 在产物里的差别是什么?(1.2.5)
  • 想让「类型」和「校验」保持一致,工程上有哪两条路?(1.2.8)

如果这几问都能答上来,说明你已经掌握了本章最核心的一对概念。

小结

  • TypeScript 是 JavaScript 的超集:合法 JS 基本都是合法 TS,但少数写法会被拒绝,--strict 还会额外加检查。
  • 类型擦除意味着 interface、type、类型注解在编译产物里一行不剩;enum、装饰器、namespace、参数属性是少数例外,它们会生成真实代码。
  • 擦除留下三个洞:运行时类型信息残缺、编译期类型拦不住外部数据、类型专用 import 的行为随配置变化。对策分别是类型守卫、运行时校验(Zod)、import type。
  • 最有效的学习方式:亲手编译一次,对照源码与产物。

下一节我们把视野拉远,看看 TypeScript 在今天的技术版图里到底被用在哪里——从 React、Vue 前端,到 NestJS、tRPC 后端,再到 Deno、Bun、边缘函数,以及围绕它长出来的一整套工具链。

阅读导航:上一节:1.1 TypeScript 的诞生与设计目标 · 下一节:1.3 适用场景与生态版图 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

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