TypeScript:让静态类型止于可信边界
最后核验:2026-06-30|适用版本:TypeScript 5.8–6.0,7.0 RC 仅作迁移观察|前置知识:JavaScript
静态类型在 JavaScript 工程中的职责
TypeScript 在 JavaScript 语法上增加静态类型检查与编辑器服务。它擅长在提交或运行前发现属性名、联合分支、模块边界和调用约定错误,但编译后类型通常被擦除;网络响应、localStorage、用户输入和跨窗口消息不会因为写了接口就自动可信。
学习时少收集高级类型体操,多去建立“哪里需要静态关系、哪里必须运行时验证”的边界。一个能读懂的判别联合往往优于多层条件类型;一个逐字段验证器往往优于 as TechnologyEntry[]。
以下场景不应强行引入 TypeScript:一次性且无需维护的几行脚本、宿主不支持构建且类型收益极低的代码、团队无法持续运行类型检查的复制片段。反过来,长期维护的模块、共享数据契约和公开库 API 通常值得使用严格 TypeScript。是否采用由错误成本和协作规模决定,不由文件行数单独决定。
从 .ts 源码到 JavaScript 与声明文件
TypeScript 编译器的关键阶段是 Scanner、Parser、Binder、Checker、Transformer/Emitter。Scanner 把字符分成 token;Parser 建立 AST(抽象语法树);Binder 为声明建立 Symbol 和作用域关系;Checker 根据类型关系与控制流产生诊断;Emitter 按 target/module 等选项生成 JavaScript、source map 和可选 .d.ts。语法树存在不等于类型正确,类型正确也不等于运行时模块能加载。
tsc 默认可能在有错误时仍发射,是否阻止由 noEmitOnError 和构建流程决定;应用 CI 通常使用 noEmit 做类型门禁,再由 bundler 转译。Vite/esbuild/SWC 常只删除类型,不执行完整类型检查,因此“dev server 能打开”不能替代 tsc --noEmit/vue-tsc 等检查。
target、module 与 moduleResolution 分别控制什么
target 决定语法降级和默认 lib 范围,例如把 optional chaining、class field 或 async 函数转换到旧语法;它不自动注入缺失的 Promise、Map、fetch 等运行时 polyfill。module 决定发射的模块形式或保留方式;moduleResolution 决定编译器如何把 specifier 找到文件/声明。三者必须与实际运行时、package exports 和 bundler 对齐。
// input.ts
export class Counter {
#count = 0;
increment = () => ++this.#count;
}
export async function answer(): Promise<number> {
return (await Promise.resolve({ value: 42 }))?.value ?? 0;
}当 target 较新时,private field、async/await、optional chaining 可能原样保留;较旧 target 会生成 helper、WeakMap 或 generator 状态机,具体输出随版本和选项变化。工程师应实际运行 tsc --target ES2020 --module ES2022 input.ts 与目标配置比较输出,不根据语法猜体积和行为。装饰器、class field 初始化顺序、useDefineForClassFields 等更应对照产物和运行时测试。
类型擦除不是“什么都不生成”
interface、type、泛型、as、satisfies 通常完全擦除;enum、namespace、parameter property、legacy decorator 等可能生成运行时代码。import type 保证只作为类型依赖并在输出删除;普通 import 是否保留受使用方式和 verbatimModuleSyntax 影响。库作者需检查 .d.ts 与 JS 是否引用一致,避免类型消费者成功、运行时却缺导出。
interface Entry { id: string } // 擦除
type EntryId = Entry['id']; // 擦除
const value = { id: 'html' } satisfies Entry; // 输出只剩对象
enum Status { Idle, Ready } // 通常生成运行时对象用下面的最小文件直接观察擦除。保存为 compile-demo.ts:
interface Entry { id: string }
function first<T>(values: readonly T[]): T | undefined {
return values[0];
}
const entry = { id: 'typescript' } satisfies Entry;
console.log(first([entry])?.id);在仓库根目录运行:
pnpm exec tsc compile-demo.ts \
--target ES2022 --module ES2022 \
--outDir /tmp/typescript-emit预期 /tmp/typescript-emit/compile-demo.js 的核心内容如下;interface、泛型参数、readonly、返回类型和 satisfies 均已消失:
function first(values) {
return values[0];
}
const entry = { id: 'typescript' };
console.log(first([entry])?.id);
export {};更换 target 再比较产物;不要手工维护展示输出作为构建事实,真实项目以锁定 TypeScript 版本的 CI 产物为准。
项目引用(project references)把大型仓库拆成可增量构建单元,composite 产出构建信息与声明边界。只有确实存在多包依赖和重复检查成本时使用;单包应用不需要为“未来规模”预先拆 references。
结构化类型与赋值兼容
TypeScript 的工作流是解析源码、构建符号与类型关系、执行控制流分析、报告诊断,然后按配置发射 JavaScript 与可选声明文件。类型检查不改变 JavaScript 的对象模型;interface、类型别名、泛型参数等不会成为运行时构造器。
TypeScript 使用结构化类型:值是否可赋给目标类型,主要看所需成员及其类型,而不是声明名称。对象字面量在直接赋值时还有 excess property checking(多余属性检查),但这只是开发期启发式检查,不是运行时 schema。
赋值兼容并不等于双向相等。字面量可赋给更宽的 string,具有额外成员的变量可赋给只要求部分成员的接口;函数参数与返回值还受 strictFunctionTypes 等规则影响。遇到意外兼容时,应写一个最小赋值例子,而不是把鼠标提示当形式证明。
类型推断、字面量宽化与上下文类型
TypeScript 从初始化值、上下文目标、返回语句、控制流和泛型调用点推断类型。推断不是全程序定理证明,而是在可维护性、性能与 JavaScript 兼容之间取舍;公开边界应显式标注,局部变量让编译器推断。
let method = 'GET' 通常宽化为 string,因为变量可重赋;const method = 'GET' 推断字面量 'GET';对象属性即使对象绑定是 const,默认仍可能宽化,因为属性可修改。as const 递归地把字面量推断为 readonly 窄类型,但不冻结运行时对象。
let mutable = 'GET'; // string
const fixed = 'GET'; // 'GET'
const request = { method: 'GET' }; // { method: string }
const exact = { method: 'GET' } as const; // { readonly method: 'GET' }
type Method = 'GET' | 'POST';
const checked = { method: 'GET' } satisfies { method: Method };
// checked.method 保留 'GET',同时检查只能是 Method上下文类型从使用位置反推参数,例如 DOM listener 中 event 推断为 MouseEvent;把回调先保存到无注解变量可能失去上下文并触发 implicit any。复杂回调在 API 边界直接注解或使用 satisfies 检查完整映射。
best common type 与联合推断
数组 [1, null] 的结果受 strictNullChecks 与上下文影响;异构对象数组可能推断为可选属性联合,而不是预期领域类型。需要固定闭合集合时给数组元素类型或使用 satisfies,避免后续某项拼写错误被“推断成新的联合分支”。
type Route =
| { kind: 'article'; slug: string }
| { kind: 'home' };
const routes = [
{ kind: 'home' },
{ kind: 'article', slug: 'javascript' },
] satisfies readonly Route[];函数参数的逆变、返回值协变与 method 双变
在 strictFunctionTypes 下,函数属性的参数按更安全的逆变方向检查:能处理更广输入的函数可以代替只需处理窄输入的位置,反之不行;返回值通常协变。为兼容常见 JavaScript 模式,method 语法仍有双变例外,库 API 若需要严格回调关系可用函数属性。
type Animal = { name: string };
type Dog = Animal & { bark(): void };
let handleAnimal: (value: Animal) => void = () => {};
let handleDog: (value: Dog) => void = () => {};
handleDog = handleAnimal; // 安全:处理 Animal 当然能处理 Dog
// handleAnimal = handleDog; // 不安全:调用者可能传没有 bark 的 Animal类型推断异常时按顺序缩小:先悬停检查最早宽化点,再给领域边界加注解,最后才使用显式泛型参数。as 只能覆盖检查,不能改善推断来源。
控制流收窄与判别联合
控制流分析根据 typeof、in、判等、用户定义 type predicate 和可达性缩小联合类型。判别联合用共同的字面量字段表达有限状态,配合 never 做穷尽检查:
type LoadState =
| { status: 'idle' }
| { status: 'loading'; controller: AbortController }
| { status: 'ready'; count: number }
| { status: 'error'; message: string };
function message(state: LoadState): string {
switch (state.status) {
case 'idle': return '等待加载';
case 'loading': return '正在加载';
case 'ready': return `${state.count} 项`;
case 'error': return state.message;
default: return state satisfies never;
}
}收窄依赖控制流中的事实。赋值、函数调用、闭包和别名可能让事实失效;例如检查 options.location 后进入回调,编译器不能保证回调执行前属性未被修改。把已验证值复制到 const 局部变量,或缩小可变范围,而不是非空断言。
用户定义 type predicate value is T 只是编译器信任函数返回值的声明,写错实现会制造不安全;assertion function asserts value is T 适合失败即抛错的边界。两者都要有运行时反例测试。
function assertError(value: unknown): asserts value is Error {
if (!(value instanceof Error)) throw new TypeError('期望 Error 实例');
}
try {
throw new Error('加载失败');
} catch (error: unknown) {
assertError(error);
console.log(error.message);
}in 检查属性是否存在于自身或原型链,不等于验证属性类型;instanceof 依赖运行时构造器并可能跨 realm 失效;typeof object 还要排除 null。网络 JSON 通常使用逐字段结构验证,不用 instanceof。
泛型、条件类型与映射类型
泛型表达输入与输出之间的关系,而不是用单个类型参数替换 any。约束 T extends { id: string } 说明函数只依赖 id;若函数无须把具体 T 传递到返回值,普通参数类型通常更清楚。
条件类型根据类型关系选择分支,映射类型遍历属性键,模板字面量类型组合有限字符串集合。它们适合库级 API 与重复关系;如果类型错误展开数屏、编译变慢或运行时规则仍需手写,应退回显式接口。
泛型参数必须表达关系
下面 getById 的 T 同时约束输入数组和返回值,因此有真实关系;如果只在参数出现一次,普通类型往往更好。key 泛型可把属性名和返回类型关联起来:
function getById<T extends { id: string }>(
entries: readonly T[],
id: string,
): T | undefined {
return entries.find((entry) => entry.id === id);
}
function read<T, K extends keyof T>(value: T, key: K): T[K] {
return value[key];
}
const entry = { id: 'ts', score: 10, stable: true };
const score = read(entry, 'score'); // number泛型默认从实参推断。多个位置给出冲突候选时,编译器按协变/逆变位置和上下文选择结果;显式 <Type> 可以指定,但常掩盖 API 设计不清。const type parameter 倾向保留只读字面量,适合配置 builder,不会让运行时值不可变。
keyof、indexed access 与映射类型
keyof T 产生属性键联合,T[K] 读取属性值类型。映射类型遍历键并可用 readonly/? 修饰符及 as 重映射。标准工具 Partial、Required、Readonly、Pick、Omit 都建立在这些能力上。
type Entry = { id: string; score: number; stable: boolean };
type ChangeHandlers<T> = {
[K in keyof T as `on${Capitalize<string & K>}Change`]?:
(value: T[K]) => void;
};
type EntryHandlers = ChangeHandlers<Entry>;
// onIdChange?: (value: string) => void; ...键重映射适合规律稳定且消费者能理解的公共关系。只有三个固定字段时,显式接口更易查文档和报错。Record<string, T> 表示任意字符串都可能存在,但普通对象读取可能为 undefined;配合 noUncheckedIndexedAccess 或改用有限键联合。
条件类型、infer 与分布式行为
条件类型形如 T extends U ? X : Y。当检查对象是裸类型参数且 T 为联合时,会对每个成员分布;包进元组 [T] extends [U] 可关闭分布。infer 在匹配结构中声明待推断部分。
type AwaitedValue<T> = T extends PromiseLike<infer U>
? AwaitedValue<U>
: T;
type Distributed<T> = T extends string ? `${T}!` : never;
type A = Distributed<'html' | 'css'>; // 'html!' | 'css!'
type IsExactlyString<T> = [T] extends [string] ? true : false;
type B = IsExactlyString<string | number>; // false递归条件类型适合 Promise、数组、schema 等有限结构;对任意深度对象做“DeepReadonly/DeepPartial”会遇到函数、Map、Date、递归类型和实例语义,不应把一个短工具类型当完整数据模型。
overload、联合参数与条件返回
overload 为多个离散调用形状提供签名,实现签名对调用者不可见且必须兼容所有 overload。若各分支返回相同类型,优先联合参数;若输入输出强关联且分支少,overload 更清楚;泛型条件返回常需要实现内部断言,只有库 API 收益明确时使用。
function parse(value: string): URL;
function parse(value: URL): URL;
function parse(value: string | URL): URL {
return typeof value === 'string' ? new URL(value) : value;
}不要为每个可选参数组合写 overload。对象参数配判别联合通常能保留合法组合并给调用者更好的补全。
unknown 可信边界与运行时验证
下面从 unknown 验证共享 JSON 的核心字段。环境是本仓库锁定的 TypeScript 6.0.3 与 Node.js 22+。
const columns = new Set([
'web-foundations',
'frontend-frameworks',
] as const);
type ColumnId = typeof columns extends Set<infer T> ? T : never;
interface TechnologyEntry {
id: string;
name: string;
column: ColumnId;
tags: string[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readString(
record: Record<string, unknown>,
key: string,
path: string,
): string {
const value = record[key];
if (typeof value !== 'string' || value.length === 0) {
throw new TypeError(`${path}.${key} 必须是非空字符串`);
}
return value;
}
function parseEntry(value: unknown, index: number): TechnologyEntry {
const path = `[${index}]`;
if (!isRecord(value)) throw new TypeError(`${path} 必须是对象`);
const column = readString(value, 'column', path);
if (!columns.has(column as ColumnId)) {
throw new TypeError(`${path}.column 不是已知专栏`);
}
const tags = value.tags;
if (!Array.isArray(tags) || tags.some((tag) => typeof tag !== 'string')) {
throw new TypeError(`${path}.tags 必须是字符串数组`);
}
return {
id: readString(value, 'id', path),
name: readString(value, 'name', path),
column: column as ColumnId,
tags: [...tags],
};
}
export function parseEntries(value: unknown): TechnologyEntry[] {
if (!Array.isArray(value)) throw new TypeError('技术目录必须是数组');
return value.map(parseEntry);
}as ColumnId 只出现在已经用 Set.has 验证的局部位置。断言没有验证能力,所以不能把整个响应断言为 TechnologyEntry[]。如果类型来自更复杂 schema,可评估已有验证库;本示例只有固定字段,手写验证更短且错误路径可控。
在仓库根目录运行真实实现与测试:
pnpm --filter @roadmap/web-foundations test输入:测试分别传入合法数组、非数组、name: 1、未知专栏和数字前置 id。
预期输出:合法数据保持字段值;非法输入抛出包含 [0].name、[0].column 等路径的错误;tsc 在 strict、noUncheckedIndexedAccess 与 exactOptionalPropertyTypes 下编译通过。
satisfies 检查表达式可赋给目标类型,同时尽量保留表达式自身推断结果;类型注解则把变量视为注解类型。它适合配置对象和映射完整性检查,不是类型转换,也不会生成运行时校验。
unknown 表示“有一个值,但使用前必须缩小”;any 则关闭后续检查并传播。JSON 解析结果、postMessage 数据、异常和存储内容应尽早进入 unknown,通过一次窄而明确的验证后再进入领域层。
严格配置与知识浏览器领域模型
示例把领域职责放在 model.ts:ColumnId 限制专栏集合,TechnologyEntry 描述验证后的条目,AppState 描述可序列化选择状态。search.ts 只接受已验证条目,app.ts 在 Fetch/JSON 边界调用 parseTechnologyEntries。
readonly TechnologyEntry[] 表明搜索函数不修改输入;返回新数组,选择切换也返回新数组。readonly 是编译期写入限制,不会深冻结运行时对象。需要抵御第三方运行时代码修改时,再使用复制或 Object.freeze,不要混淆两个保证。
严格配置逐项解决真实风险:
| 选项 | 捕获的问题 | 项目中的作用 |
|---|---|---|
strict | 启用严格检查族 | 建立统一检查规则,避免隐式空值和函数兼容漏洞 |
noUncheckedIndexedAccess | 索引读取可能不存在 | 迫使数组、字典访问处理 undefined |
exactOptionalPropertyTypes | 缺失与显式 undefined 不同 | URL 与状态 patch 更准确 |
verbatimModuleSyntax | 保留明确的 import/export 语义 | 迫使类型导入使用 import type |
moduleResolution: Bundler | 按 bundler/现代 ESM 规则解析 | 示例源码可使用 .js 输出扩展名 |
类型工具选择表:
| 目标 | 首选工具 | 何时停止抽象 |
|---|---|---|
| 表达对象形状 | interface 或对象类型别名 | 只有一个使用点时可就地写 |
| 组合有限状态 | 判别联合 | 分支开放且由插件注册时再设计扩展点 |
| 保留输入输出关系 | 带约束泛型 | 返回值不依赖具体输入类型时不用泛型 |
| 派生属性集合 | Pick、Omit、映射类型 | 派生名比显式接口更难读时直接声明 |
| 根据类型选择结果 | 条件类型 | 多层分发导致诊断不可读时拆开 |
| 检查配置且保留推断 | satisfies | 需要变量被宽化时用类型注解 |
| 接收不可信值 | unknown + 验证器 | 不用 as 或 any 绕过边界 |
类型诊断、检查性能与断言风险
调试类型错误先缩小到一个赋值关系:实际类型是什么、目标类型是什么、哪一成员不兼容。使用编辑器 Quick Info 和 tsc --pretty false 获取可复现诊断;不要通过连写 as unknown as T 消音。
类型检查性能常被巨大联合、递归条件类型、跨项目重复包含和过宽 include 拖慢。先运行 tsc --extendedDiagnostics 或生成 trace,再减少类型工作量。将每个 JSON 字段变成复杂模板字面量不会提高运行时安全,反而提高编译成本。
声明文件是发布边界。库应显式导出稳定类型,避免公开函数返回无法命名的内部推断结构;应用内部不需要为每个模块生成 .d.ts。skipLibCheck 能减少依赖声明检查,但也可能隐藏重复或冲突声明,不能当默认“修复”。
安全上,类型断言、非空断言与环境声明都只是对编译器的承诺。攻击者传入的 JSON 不受这些承诺约束。所有信任边界必须运行时验证长度、协议、枚举和嵌套结构;URL 必须再交给 URL 解析并限制协议,DOM 输出继续用安全 API。
常见误区:
private、readonly和接口不会天然形成运行时访问控制或冻结。enum可能生成运行时代码;固定字符串联合通常更透明,但不是绝对替代。as const缩窄字面量并添加只读推断,不深冻结对象。- 可选属性
x?: string在精确可选语义下不等于x: string | undefined。 catch中的值不保证是Error,应先缩小。- 类型测试通过不代表模块在目标浏览器可加载,发射格式与运行时仍需集成验证。
TypeScript 6.0 默认值与 7.0 迁移
TypeScript 5.x 奠定了 satisfies、const 类型参数、装饰器更新、更准确的控制流与模块解析演进。升级时只记录会改变工程决策的默认值、弃用项和发射行为,不罗列所有语法小改动。
TypeScript 6.0 于 2026-03-23 发布,是现有 JavaScript 代码库的最后一个主要版本,并作为 5.9 到原生 7.0 的桥梁。重要变化包括:strict 默认开启、module 默认 esnext、target 默认当前年度 ECMAScript、noUncheckedSideEffectImports 默认开启;rootDir 在有配置文件时默认配置目录;lib.dom 合并 iterable 声明;旧模块语法和 import assertions 等进入弃用路径。TypeScript 6.0 Release Notes
本示例仍显式写出 target、module、rootDir 与严格选项,避免浮动默认值让教学结果随编译器版本变化。--stableTypeOrdering 只用于诊断 6.0 与 7.0 输出差异,官方说明它可能显著减慢检查,不应长期默认开启。
截至 2026-06-30,TypeScript 7.0 处于 RC,编译器和语言服务已移植到 Go,目标是利用原生执行和共享内存并行提升性能。RC 不是本仓库的稳定版本;迁移应先让 6.0 在无 ignoreDeprecations、必要时启用稳定类型排序的条件下通过,再在独立 CI 作对比。TypeScript 7.0 RC
模板字面量类型与高阶推断的边界
模板字面量类型适合从有限联合推导事件名、路由键或 CSS 变量名;它不能验证任意运行时字符串。高阶推断则关注“函数接收函数并保留其参数和返回关系”,常用 Parameters<T>、ReturnType<T>、条件类型中的 infer 和 const 类型参数。两者都应服务于真实 API 关系,而不是把业务规则移进编译器。
type Column = 'web' | 'framework';
type EventName = `${Column}:selected`;
function defineEvents<const T extends readonly EventName[]>(events: T): T {
return events;
}
const events = defineEvents(['web:selected', 'framework:selected']);
type FirstEvent = typeof events[0]; // 'web:selected'| 难点 | 正确工具 | 停止信号 |
|---|---|---|
| 有限字符串组合必须一致 | 模板字面量类型 | 值来自 URL/数据库时改做运行时枚举验证 |
| 包装函数要保留调用签名 | 泛型 + Parameters/ReturnType | overload、this 或异常语义被包装器悄悄丢失 |
| 从容器提取成员类型 | 条件类型 + infer | 多层分发让错误信息无法定位 |
| 配置键映射为处理器 | 映射类型 | 运行时仍需动态注册且集合开放 |
| 根据分支得到不同结果 | 判别联合或 overload | 调用者只得到宽联合且必须断言 |
严重类型回归往往不是运行时代码立刻崩溃,而是声明输出、模块解析或推断改变后让错误扩散到所有消费方。升级 TypeScript 时必须比较 tsc --noEmit、公开 .d.ts、构建产物和编辑器诊断;只让本包源码通过不足以证明库边界兼容。TypeScript Conditional Types、Template Literal Types
类型工具选择与停止抽象的边界
| 方案 | 静态关系 | 运行时验证 | 适用边界 |
|---|---|---|---|
| JavaScript + JSDoc | 中等 | 手写或库 | 小型 JS 项目、渐进迁移 |
| TypeScript 接口 | 强 | 无 | 已可信的模块内部数据 |
| Type guard / assertion function | 强 | 由实现决定 | 少量固定边界与定制错误 |
| Schema 验证库 | 强,通常可推导 | 完整 | 大量复用 schema、复杂组合与序列化 |
| 代码生成 | 强 | 取决于生成器 | OpenAPI/IDL 是单一事实源的大系统 |
选择原则:已有契约与工具优先复用;只有五个固定条目的教学示例不新增 schema 依赖。若未来 API 字段大幅增长、前后端共享 schema 或需要错误国际化,再引入验证库或代码生成。
编译诊断与数据验证练习
- 对一个类型关系分别用注解与
satisfies,观察推断差异。 - 把
unknownJSON 逐步缩小为判别联合,并为错误加入字段路径。 - 删除某个联合分支处理,确认
satisfies never报错。 - 打开
noUncheckedIndexedAccess与exactOptionalPropertyTypes,修复真实诊断而非断言绕过。 - 用一个泛型约束表达输入输出关系,再判断普通接口是否更简单。
- 运行
tsc --extendedDiagnostics,找出类型检查最耗时指标。 - 对照 TypeScript 6.0 迁移说明检查默认值与弃用项。
- 在独立分支试跑 TypeScript 7.0 RC,比较诊断、声明输出与构建时间,不直接替换稳定工具链。
验收标准:能明确指出静态类型保证到哪里结束,能在边界提供运行时证据,并能用最简单的类型工具表达关系。若需要断言才能让常规数据流通过,应先检查模型或验证器设计。
TypeScript 手册与发布资料
- TypeScript Handbook:类型系统与工具链入口;核验于 2026-06-30。
- TypeScript 6.0 Release Notes:默认值、弃用项与 7.0 迁移桥梁;核验于 2026-06-30。
- TypeScript Narrowing:控制流分析、type guard 与判别联合;核验于 2026-06-30。
- TypeScript Generics:泛型关系与约束;核验于 2026-06-30。
- TypeScript Compiler Options:配置语义;核验于 2026-06-30。
- microsoft/typescript-go:TypeScript 原生端口开发仓库;核验于 2026-06-30。