《TypeScript编程入门》13.1 类型擦除带来的运行时盲区

本节从类型擦除讲起:TypeScript 编译时会把 interface、类型注解与断言全部抹掉,只留下 JavaScript,于是「编译通过」并不等于「运行时安全」。我们会逐一拆开真实项目里最容易出事的几类盲区,包括 JSON.parse 后的断言、越界索引、Object.keys 的字符串键、结构化类型的多余属性、非空断言与异步回调,并给出信任边界清单,帮你判断校验该加在哪里。

本节目标:读完这一节,你能说清「类型擦除」到底擦掉了什么、又留下了什么;能列出真实项目里最容易出事的几类运行时盲区,并把它们和具体的报错信息对上号;能在自己的代码里画出「信任边界」,判断哪一处数据必须校验、哪一处可以放心依赖类型。本节是第 13 章的开篇,负责提出问题;接下来的两节负责给出解法。

13.1 类型擦除带来的运行时盲区

前面十二章,我们一直在训练一件能力:让类型系统替我们提前发现错误。从原始类型到泛型,从判别联合到条件类型,所有的努力都指向同一个目标——把错误从运行时提前到编译时。

但这一切有一个前提,而这个前提经常被忽略:TypeScript 的类型只存在于编译期。tsc 做的事情,本质上就是把类型语法剥掉,输出一份普通的 JavaScript。运行时没有任何东西记得你写过 interface User。

于是出现了一个危险的落差:

  • 编译器说「这段代码没问题」,它检查的是你声称的类型;
  • 运行时真正跑的是数据的实际形状。

两者一致时万事大吉;不一致时,tsc 一声不吭,程序在半夜两点崩溃。

擦除到底擦掉了什么

先看一份最小的对照。左边是源码,右边是 tsc 的产物(target: ES2020):

// 源码:user.ts
interface User {
  id: number;
  name: string;
}

type Id = User["id"];

function greet(user: User, suffix?: string): string {
  return `Hello, ${user.name}${suffix ?? ""}`;
}
// 产物:user.js
"use strict";
function greet(user, suffix) {
  return `Hello, ${user.name}${suffix ?? ""}`;
}

interface 不见了,type 不见了,参数注解不见了,返回类型注解不见了,可选参数的 ? 变成了「运行时可能是 undefined」这一事实——而编译器对此毫不知情。函数签名从 (user: User, suffix?: string) => string 退化成 (user, suffix) => string,任何对象都能传进来。

这就是「类型擦除(type erasure)」:类型是给编译器看的注释,不是运行时的守卫。

哪些语法在运行时留下了痕迹

擦除并非百分百彻底。少数语法会生成真实的 JavaScript 代码,理解这份差异能帮你判断「哪里还有一点运行时保障」:

语法是否生成运行时代码说明
interface / type否完全消失
类型注解、类型断言 as否完全消失
import type / export type否整条 import 被删除
enum是生成一个双向映射对象
const enum默认内联成员被替换成字面量,除非开 preserveConstEnums
namespace是生成 IIFE 与对象
类是类本身是值,但字段类型注解消失
参数属性 constructor(private x)是生成赋值语句,类型消失
装饰器是生成装饰器调用;元数据类型需 emitDecoratorMetadata

import type 值得单独强调。它不只是「更明确」,它决定了编译产物里根本没有这条 import:

// 只用来做类型标注 —— 必须写 import type
import type { User } from "./types";

// 既做类型又做值 —— 普通 import
import { UserSchema } from "./schemas";

如果误把纯类型导入写成普通 import,在 verbatimModuleSyntax 关闭时通常无碍,但在 ESM + 打包器组合下可能留下一条指向不存在导出的 import,运行时直接报错。这属于 11.1 ES 模块与模块解析 讨论的范围。

盲区一:as 断言把「我希望」写成了「它是」

最经典的场景是解析外部数据:

interface User {
  id: number;
  name: string;
  email: string;
}

const raw = '{"id":1,"name":"Ada"}'; // 后端这次少给了 email

const user = JSON.parse(raw) as User;

console.log(user.email.toUpperCase());
// 运行时:TypeError: Cannot read properties of undefined (reading 'toUpperCase')

tsc 全程零报错。因为 JSON.parse 的签名是 (text: string) => any,而 any 可以断言成任何类型。as User 并没有检查任何东西,它只是让编译器闭嘴。

关键认知:as 不是转换,是承诺。你向编译器承诺「这里一定是 User」,编译器就不再追问。承诺错了,代价由运行时承担。

顺带一提,JSON.parse 的 any 返回值与 as 的组合,是 3.3 any·unknown·never·void 与类型断言 里强调过的最大风险点。

盲区二:索引访问的「假非空」

数组越界在 TypeScript 里是无声的:

const items = ["a", "b"];
const third = items[2]; // 类型被推断为 string
third.toUpperCase();    // 编译通过
// 运行时:TypeError: Cannot read properties of undefined (reading 'toUpperCase')

编译器默认认为「你访问了就一定存在」,因为 noUncheckedIndexedAccess 默认是关闭的。打开它,类型会诚实地变成 string | undefined:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true
  }
}

开启后上面的代码立刻变成编译错误:

'third' is possibly 'undefined'. ts(18048)

同样的坑出现在 Record 与索引签名上:

type Dict = Record<string, number>;
const d: Dict = { a: 1 };

const v = d["missing"]; // 类型 number,实际 undefined
console.log(v.toFixed(2)); // 运行时 TypeError

注意:strict: true 并不包含 noUncheckedIndexedAccess。这是新手最常见的误解之一。

盲区三:Object.keys 返回的不是 keyof

interface Config {
  host: string;
  port: number;
}

const cfg: Config = { host: "localhost", port: 8080 };

const keys = Object.keys(cfg); // 类型:string[]
keys.forEach((k) => console.log(cfg[k]));
// 编译报错:Element implicitly has an 'any' type because expression of type
// 'string' can't be used to index type 'Config'. ts(7053)

Object.keys 的返回类型被故意设计成 string[] 而不是 (keyof T)[],因为在结构化类型的世界里,对象完全可能比它声明的类型多出几个属性(下一节就会讲到)。要遍历键,需要显式收窄:

const keys = Object.keys(cfg) as (keyof Config)[];
keys.forEach((k) => console.log(cfg[k])); // 通过

或者写一个泛型辅助函数,把断言集中在一处:

function typedKeys<T extends object>(obj: T): (keyof T)[] {
  return Object.keys(obj) as (keyof T)[];
}

盲区四:结构化类型的「多余属性」

TypeScript 是结构化类型系统:只要形状兼容,就认为可赋值。这意味着运行时对象可以比类型声明的多出字段:

interface Point {
  x: number;
  y: number;
}

const raw = { x: 1, y: 2, z: 3 };
const p: Point = raw; // 合法:变量赋值不做多余属性检查

console.log(Object.keys(p)); // [ 'x', 'y', 'z' ]
console.log(JSON.stringify(p)); // {"x":1,"y":2,"z":3}

z 被静默带进了后续所有环节。如果这个对象最终被写进数据库或者发给下游,多出来的字段就成了一次数据泄漏。

只有在对象字面量直接赋值时,编译器才会做多余属性检查(excess property check):

const q: Point = { x: 1, y: 2, z: 3 };
// 编译报错:Object literal may only specify known properties,
// and 'z' does not exist in type 'Point'. ts(2353)

一旦经过变量中转,这层保护就消失了。相关取舍在 5.3 结构化类型与两者取舍 中有更完整的讨论。

盲区五:非空断言与双重断言

function findUser(id: number): User | undefined {
  // ...
  return undefined;
}

const u = findUser(1)!; // 用 ! 抹掉 undefined
console.log(u.name);
// 运行时:TypeError: Cannot read properties of undefined (reading 'name')

! 和 as 是同一类东西:对编译器的承诺。它们的共同特点是——写起来只需一个字符,出事时要花两小时排查。

更糟的是双重断言,它能把两个毫无关系的类型连起来:

const s = "hello" as unknown as number; // 编译通过
s.toFixed(2); // 运行时:TypeError: s.toFixed is not a function

盲区六:异步间隙里的收窄失效

类型收窄(narrowing)是编译期的推理,它无法约束「两个语句之间世界发生了什么」:

interface State {
  user?: { name: string };
}

const state: State = {};

if (state.user) {
  const name = state.user.name; // 此处收窄成立
  setTimeout(() => {
    console.log(name.toUpperCase()); // 安全:name 是字符串的拷贝
  }, 0);
}

上面这段是安全的,因为 name 拷贝了一个字符串。危险的是把整个对象带进异步回调:

if (state.user) {
  setTimeout(() => {
    console.log(state.user.name.length); // 收窄已失效,可能抛错
  }, 0);
}

对象是引用,回调执行时 state.user 完全可能已经被别处清空。TypeScript 对 let 变量和属性访问在闭包内会重置收窄,但这依赖它能否静态判断——通过函数参数传出去、经过 any 中转、或者被 await 隔断之后,它就无能为力了。

盲区七:类型与运行时格式不一致

这一类最隐蔽:类型本身没错,错的是「JSON 里的表示」和「类型声称的表示」不一致。

interface Order {
  id: number;
  amount: number;
  createdAt: Date; // ← 危险
}

const res = await fetch("/api/orders/1");
const order = (await res.json()) as Order;

console.log(order.createdAt.getTime());
// 运行时:TypeError: order.createdAt.getTime is not a function
// 因为 JSON 里 createdAt 是字符串 "2026-09-13T10:00:00+08:00"

Date 在 JSON 里永远是字符串。number 也可能是 "1"。这类错误在类型层面完全看不见,因为 as Order 已经把一切问题挡在了门外。

把报错对上号

上面这些盲区,落到运行时会变成几种固定的报错。熟悉它们能显著缩短排查时间:

报错信息常见成因
Cannot read properties of undefined (reading 'x')as 断言、!、越界索引
Cannot read properties of null (reading 'x')上游返回 null,类型写成了对象
x is not iterable字段缺失,for...of 拿到 undefined
x.toUpperCase is not a function类型写成 string,实际是数字或对象
Unexpected token < in JSON at position 0请求返回了 HTML 错误页,却直接 JSON.parse
Converting circular structure to JSON结构化类型带来的意外字段形成环

其中 Unexpected token < 值得单独记住:< 是 HTML 的开头。它几乎总意味着「网关或鉴权中间件返回了错误页面,而你的代码以为拿到的是 JSON」。

画一张信任边界图

既然类型靠不住,那就得换一个思路:不追问「编译器信不信」,而追问「这份数据从哪来」。

数据从可信代码流到可信代码,类型足够;数据从外部跨进你的进程,就必须在跨进来的那一刻校验。

边界数据来源可信?应做的动作
HTTP 响应体第三方或后端服务否schema 校验后使用
HTTP 请求体客户端否入口处 schema 校验
process.env部署环境否启动期校验,失败即退出
JSON / YAML 配置文件运维、其他团队否启动期校验
localStorage / Cookie用户可随意修改否读取时校验
消息队列 / 事件总线其他服务否消费端校验
数据库返回值你控制的库表基本可信ORM 层类型即可
同模块内函数参数自己的代码是依赖类型

这张表是本章的地图。第 13.2 节解决「用什么工具校验」,第 13.3 节解决「把校验放在哪几处」。

一条原则:Parse, don’t validate

传统写法是「先断言,用到时再检查」:

// ❌ 检查散落在各处,很容易漏
const user = JSON.parse(raw) as User;
if (!user.email) {
  throw new Error("email 缺失");
}

更好的写法是「进门时就解析成一个可信类型」:

// ✅ 解析一次,之后全程可信
const user: User = parseUser(raw); // 解析失败直接抛错,成功则保证形状正确

区别在于:前者让「不可信数据」在系统里流动,每个使用点都要自己防御;后者在边界处把不可信数据转换成可信数据,类型从此不再是承诺,而是已经兑现的事实。

要写出 parseUser,我们需要一个能在运行时真正检查数据形状的工具。这正是下一节的主角。

小结

  • 类型擦除意味着 tsc 只做语法层面的剥离,运行时不保留任何类型信息;编译通过只代表「你声称的类型自洽」,不代表「数据真的是那个形状」。
  • interface、类型注解、as 断言、import type 都会被完全删除;只有 enum、namespace、类、装饰器等少数语法留下运行时代码。
  • 七类高频盲区:as 断言外部数据、越界索引、Object.keys 的 string[]、结构化类型的多余属性、! 非空断言、异步间隙中的收窄失效、类型与 JSON 表示不一致。
  • strict: true 不包含 noUncheckedIndexedAccess,索引访问的「假非空」需要单独开启。
  • 判断是否需要校验的方法不是看类型,而是看数据来源:跨进程边界的输入一律不可信,应当在边界处一次解析干净(Parse, don’t validate)。

下一节我们引入 Zod,把上面这张信任边界表里「应做的动作」真正落地:用一个 schema 同时描述运行时的检查规则和编译期的类型,让两者不再各写一份。

阅读导航:上一节:12.3 模块扩充与全局类型增强 · 下一节:13.2 Zod 模式验证与类型推导 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

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