JavaScript 灵活但"运行时才报错",项目一大就失控。TypeScript 给 JS 加了一套静态类型系统——类型既是文档、又是编译期保险。本页不重复 JS 基础(见前端基础页),聚焦 类型思维:从 any 陷阱到泛型、联合/交叉、类型体操、装饰器,再到 tsconfig、.d.ts、与框架结合、编译运行时的真相。我会把每个点拆成"是什么 → 为什么 → 怎么用 → 踩什么坑"。基线:TypeScript 5.x(tsc / tsx)。
类型系统基础:推断、字面量、联合 / 交叉
TS 不是"给每个变量写类型"那么无聊。它最便宜的能力是自动推断,最值钱的能力是把"可能"精确表达出来。这一章是后面一切的地基。
类型推断与控制流分析
TS 大部分时候能从字面量反推类型,你不必处处标注。更厉害的是控制流分析:它会在 if、赋值、返回之后,缩小变量的类型范围——所以下面的 name 在 if 里被判定为 string,不会让你调 toUpperCase 时报错。
论为什么推断比标注更省心
标注类型要手写两遍(声明 + 用到的地方),推断让编译器替你干。但函数参数和导出 API 建议显式标注——那是"对外契约",写清楚能让调用方和编译器都有依据,也避免推断出的类型比你想的更宽。
字面量类型与 const 断言
TS 里 'success' 不只是 string,它是一个字面量类型——只等于这个具体值。配合 as const 能把整个对象"冻"成只读的字面量集合,常用来定义配置/枚举。
联合类型与收窄(typeof / instanceof / in)
联合类型 A | B 表示"可能是其中任一种"。光联合不够,你得在运行前收窄到具体类型才能安全调用它的专属方法。三种常用收窄手段:
| 收窄手段 | 适用 | 写法 |
|---|---|---|
typeof | 基本类型 | typeof x === 'string' |
instanceof | 类实例 | x instanceof Date |
in | 对象区分 | 'role' in x |
| 字面量判别 | 可辨识联合 | x.kind === 'a' |
交叉类型:把多个类型"拼"在一起
交叉类型 A & B 表示"同时具备 A 和 B 的全部成员"——常用来给已有类型叠加字段,比继承灵活。
若 A 和 B 都有 x 但类型不兼容(如 string 与 number),交叉后 x 变成 never——意味着这个类型不可能被满足。同名属性要么类型一致,要么用联合而非交叉。
泛型基础:通吃类型又不丢信息
泛型让同一份逻辑适配多种类型,且保留具体类型。关键区别于 any:泛型在调用时把具体类型"接住",后续操作仍带类型检查。
论泛型 vs any 的本质区别
any 把类型信息整个丢掉了,函数返回后编译器一无所知;泛型在调用点把 T 实例化成真实类型(如 string),返回值和后续用法都还带检查。要"通吃"又不想"失明",用泛型不用 any。
类型断言 as 与非空断言 !
有时你比编译器更清楚类型,用 as 断言"这就是某类型"。但断言是"关掉检查",用错运行时才炸。还有非空断言 !,告诉编译器"这里绝不为 null/undefined"。
as 不产生任何运行时代码,它只是对你(和编译器)说"信我"。如果实际上类型不对,运行时该错还错。number as string 这种跨家族断言必须先 as unknown,因为它本质上不安全。能用类型守卫(is X)就别用 as。
结构化类型:TS 怎么判断"类型相等"
TS 用结构化类型(鸭子类型)而非名义类型:两个类型只要"形状兼容"就认为可互相赋值,不关心名字。这点和 Java/C#(名义类型)相反,常让新手困惑。
| 场景 | TS 行为 | 说明 |
|---|---|---|
| 多字段赋少字段 | 允许 | {a,b} 可赋给需要 {a} 的地方 |
| 少字段赋多字段 | 报错 | 缺属性不满足形状 |
| 同形状不同名 | 允许 | 结构一致即兼容(鸭子类型) |
| 想按"名字"区分 | 用 branded type | type USD = number & {__brand} |
论结构化类型让"组合"更自然
名义类型里,两个不同名的同类结构不能互用,得写转换。结构化类型下,只要字段对得上就能传,写工具函数/组合更顺。代价是"同名不同义"的类型可能被误当兼容——敏感场景(金额/ID)用 branded type 打上不可见标记,强制区分。
类型体操:条件 / 映射 / infer / 递归 / 模板字面量
"类型体操"是用类型系统本身做计算:在编译期由输入类型推导出输出类型。业务开发够用即可,但写库、工具链、DSL 时它是硬通货。这一章把几个核心原语讲透。
条件类型:T extends U ? X : Y
条件类型像类型的 if:如果 T 可赋值给 U,结果是 X,否则 Y。常配合泛型做"类型分发"。
把 T 包进数组/元组(如 [T] extends [U])会关掉分发,当整体判断。想要分发就保持 T extends U 裸着;想要整体判断就加方括号。这是 90% 类型体操 bug 的来源。
infer:在条件类型里"提取"一部分
infer 让你在 extends 的模式里"占位并捕获"某个子类型,相当于类型层面的"解构赋值"。
论infer 是类型体操的"瑞士军刀"
数组元素、函数返回值/参数、Promise 内层、字符串分段……几乎所有"从一个复杂类型里抠出一块"的需求,都靠 infer。它和元组、条件类型组合,能表达非常精巧的类型变换。
映射类型:遍历键,批量改造
映射类型用 keyof + 索引签名,遍历一个类型的所有键,对每个键做改造(加可选、加只读、换值类型)。utility types 大多建立在它之上。
递归类型:深只读 / 深 Partial
内置 Readonly 只处理一层。要"递归到所有嵌套对象",让类型在定义里引用自己即可——TS 支持类型层面的递归(有深度上限,约 50 层)。
递归类型在实例化时会真的"展开"每一层,超深的对象会让编译器超时或触达深度上限报错。业务里多数情况用一层 Readonly 就够了,深递归留给确实需要的配置/状态快照场景。
模板字面量类型:用字符串拼出类型
TS 支持"模板字面量类型"——像 JS 模板字符串那样,把字面量类型拼接成新类型。最实用的是自动生成事件名 / CSS 属性名这类有规律的字符串类型。
论类型体操的边界
模板字面量、递归、infer 组合起来能做很炫的事(如根据 SQL 生成类型安全的查询构造器)。但记住:类型越复杂,编译越慢、同事越看不懂。业务代码里用现成 utility types 即可,类型体操留给库作者。
内置 utility types 速查:少写重复类型
TS 自带一批"类型层面的小工具",覆盖绝大多数日常变换。记熟它们,能省下大量手写映射类型。
| 工具 | 作用 | 示例 |
|---|---|---|
Partial<T> | 所有字段变可选 | 更新 DTO |
Required<T> | 所有字段变必填 | 补全默认值后 |
Pick<T, K> | 只取部分字段 | 列表预览对象 |
Omit<T, K> | 排除部分字段 | 去掉 password |
Record<K, V> | 构造键值映射 | 字典对象 |
ReturnType<F> | 取函数返回类型 | 复用返回结构 |
Awaited<T> | 取 Promise 内层 | async 结果类型 |
论utility types 是"类型体操的成品"
你手写映射类型练手能加深理解,但业务里直接调 Pick/Omit/Partial 可读性更高、别人也认得。类型体操的功夫,留给那些"现成工具做不到"的定制变换。
装饰器与元编程
装饰器用 @xxx 给类/方法/属性"贴标签",由框架读取并注入行为。NestJS、Angular、Spring 风格的后端都靠它做依赖注入与 AOP。TS 5 后走向 TC39 标准装饰器,旧项目还有 experimentalDecorators 模式——两者写法有差异,别混。
装饰器是什么:给代码"贴标签"
装饰器本质是一个在定义时被调用的函数,它接收"被装饰的目标"(类/方法/属性),可以读取、替换或增强它。比继承/混入更声明式。
方法 / 属性 / 参数装饰器
不同位置的装饰器拿到不同参数。方法装饰器能拿到"原型、方法名、属性描述符",常用来包裹原方法(加日志/缓存/鉴权)。
论装饰器最适合"横切关注点"
日志、缓存、鉴权、重试、事务——这些和"业务逻辑本身"无关、却要套在多个方法上。装饰器让你把这些"横切"抽出来复用,业务方法保持干净。这就是 AOP(面向切面编程)在前端的落地。
TC39 标准装饰器 vs 旧 experimentalDecorators
2023+ 的 TS 支持TC39 标准装饰器(语法更像 JS 提案,用 (value, context) => 形式),而老项目用的是 experimentalDecorators + emitDecoratorMetadata。两者不兼容,迁移要注意。
| 项 | experimentalDecorators(旧) | TC39 标准(新) |
|---|---|---|
| 开启 | tsconfig 开 experimentalDecorators | target ≥ ES2022 默认支持 |
| 签名 | 靠参数位置区分目标 | 统一 (value, context) |
| 元数据 | 依赖 emitDecoratorMetadata | 自己显式声明 |
| 框架 | NestJS 旧版、Angular | NestJS 新、新项目 |
同一项目若同时开 experimentalDecorators 又写 TC39 语法,编译会冲突或行为诡异。新项目直接用标准装饰器;维护老 NestJS 项目时保持旧模式,升级前查框架文档确认支持到哪套。
反射 reflect-metadata 与依赖注入
reflect-metadata 让装饰器在运行时读取类型信息(通过 emitDecoratorMetadata 把参数类型写进元数据),依赖注入容器据此自动 new 出依赖。NestJS 的 @Injectable 就是这套机制。
实战:用装饰器做缓存与权限
两个高频场景:① 方法结果缓存(相同参数直接返回);② 接口权限校验(没权限抛错)。都是"包一层"的套路。
论装饰器不是银弹
装饰器让代码声明式,但叠加太多会"行为藏在注解里",新人读不懂"这个方法到底被套了什么"。装饰器逻辑要有单测覆盖,且每个装饰器只做一件事。别用它替替换可读性更差的魔法。
装饰器的执行顺序:初始化时从上到下
多个装饰器叠在同一目标上,执行顺序是工厂函数从下往上求值、装饰器本身从上往下应用。理解它,才能预测"谁先跑、副作用顺序对不对"。
如果两个装饰器一个注入依赖、一个读取依赖,顺序反了就会读到 undefined。写装饰器时别假设顺序"恰好对",要么让装饰器彼此独立,要么显式约定顺序并在注释里写清。
工程:tsconfig、.d.ts、第三方类型
写对 TS 不等于"能编过"。tsconfig.json 决定严格度、模块解析、产物形态;.d.ts 是"给无类型 JS 包补说明书";第三方类型怎么找、怎么补,是日常高频操作。
tsconfig 关键项:strict / paths / moduleResolution
三个最常动的项:strict(全严格检查)、paths(路径别名)、moduleResolution(怎么找模块)。配错一个,满屏报错或运行时找不到模块。
| 配置 | 作用 | 推荐 |
|---|---|---|
strict | 打开全套严格检查 | true |
noImplicitAny | 禁止隐式 any | true |
moduleResolution | 模块查找策略 | bundler / node16 |
paths | 路径别名 | @/* → src/* |
verbatimModuleSyntax | 强制 type 导入显式 | true(新项目) |
开 strict 会暴露大量你以前忽略的隐患(可能为 null、隐式 any)。忍住别关,逐个修掉——这恰是 TS 在替你还技术债。新项目一律 strict 起步;老项目用 tsc --strict --noEmit 渐进开。
编译目标与模块解析:bundler vs node16
moduleResolution 选错,import 在 TS 里能过、运行时却找不到模块。用打包器(Vite/webpack)选 bundler;写 Node 运行时选 node16/nodenext,它会严格按 package.json 的 exports 和 type 解析。
论为什么 Node 要写全扩展名
Node 运行时没有"解析器"帮你猜 ./foo 是 .ts 还是 .js。TS 在 NodeNext 模式下强制写全,是为了让"编译后的 .js"能直接在 Node 跑通。打包器模式因为有 bundler 兜底,所以可以省略。
类型声明 .d.ts 与 declare
遇到没类型的 JS 库,或要描述全局变量/环境,用 .d.ts 写"只有类型、没有实现"的声明。declare 告诉编译器"这个东西存在,别报找不到"。
第三方类型:@types 与类型查询
绝大多数流行库的类型在 @types/xxx(DefinitelyTyped)或自带 types 字段。装错/漏装,import 时就报 Could not find a declaration file。
实在没类型又赶时间,有人写 // @ts-ignore 或 declare module 'x'; 糊弄。这是债务:@ts-ignore 会压制下一行所有报错,连真错一起压。优先用 // @ts-expect-error(只有确实该报错被消除时才合法),或正经写 .d.ts。
ambient module 与全局类型
除了给具体模块补类型,ambient 声明还能描述"整个环境"(如 import.meta.env、CSS 模块、静态资源导入)。Vite 项目一般有一份 vite-env.d.ts 做这事。
论类型缺失时先问"谁该负责"
优先让库自带类型(提 issue / 用有类型的替代品);其次装 @types;最后才自己写 .d.ts。自己补的声明要窄而准,别一股脑 any,否则补了等于没补。
tsc 输出结构与增量编译
tsc 默认按 outDir 镜像源目录结构输出 .js;开 --incremental 会写 .tsbuildinfo,下次只重编改动文件。理解产物结构,才能和打包器/Node 运行时正确对接。
论声明文件要随包发布
你写的库若想被别人以"有类型"的方式引用,编译时要开 declaration: true 产出 .d.ts,并在 package.json 的 types 字段指向它。否则别人 import 你的包又会回到"找不到声明"的老路——类型即契约,得发出去才算数。
与框架结合:React / Vue 里的 TS
TS 的真正价值在框架里兑现:组件 props 有类型、hooks 推断正确、Vue 的 ref 不丢类型。这一章讲"在 React/Vue 里把类型用对"的高频套路。
React 泛型组件与 props 类型
组件本质是个函数,所以泛型组件就是"带 <T> 的函数组件"——常用于列表、表单这类要保留数据类型的组件。
类型守卫与可辨识联合
运行时判断类型要靠类型守卫——一个返回 x is T 的函数。配合"可辨识联合"(每个分支带一个字面量 type 字段),能在 switch 里自动收窄。
论为什么不用 as 强转
as 是"我比编译器更懂",用多了类型系统形同虚设,且一旦判断错了运行时才炸。is X 守卫让编译器跟着你的运行时检查一起收窄,既安全又可复用。能用守卫就别用 as。
Hooks 类型:useRef / useState / 自定义 hook
React hooks 的类型推断大多开箱即用,但 useRef 的"可空初始值"和自定义 hook 的返回类型常踩。
useState<User | null>(null) 必须显式给泛型,否则 TS 从 null 推不出 User,后面赋值 User 会报错。凡初始值是 null/undefined 且后续会变,一定显式标注泛型。
Vue 3 组合式 API + <script setup lang="ts">
Vue 3 的组合式 API 对 TS 友好。<script setup lang="ts"> 里用 ref/reactive,类型基本自动推断。
defineProps / defineEmits 与类型
Vue 里用基于类型的 defineProps / defineEmits,直接传类型而非运行时选项,既简洁又能复用到父组件。
论两个框架的 TS 哲学差异
React 是"TS 原生"——组件就是函数,类型直接套;Vue 用编译器在 SFC 里做了类型推导,写法更紧凑但依赖工具链。无论哪个,把 props/emit/state 的类型写清楚,是组件可维护的前提。别因为"能跑"就留 any。
Vue 响应式类型的两个坑
Vue 的响应式包裹(ref/reactive)在类型上也有"代价",两个最常见的坑:解构丢响应性、ref 在模板外要多写 .value。
| 坑 | 现象 | 解法 |
|---|---|---|
| reactive 解构 | 解出的字段不再响应式 | 用 toRefs |
| ref 在 JS 里 | 要 .value 才能取值 | 模板里自动解包,JS 里写 .value |
| ref 包裹对象 | 多余一层(reactive 更合适) | 对象用 reactive |
| 类型丢失 | 未标注泛型推断成宽泛类型 | 显式 ref<T>() |
论框架的"响应式"和 TS 的"不可变"是两件事
Vue 的 ref/reactive 解决"数据变了视图自动更新"(运行时效能);TS 的 readonly/DeepReadonly 解决"编译期防止误改"(安全)。别混淆:加了 readonly 不代表它不响应式,加了 ref 也不代表它不可改。
编译与运行时:tsc、tsx、ESM/CJS 互操作、性能
最关键也最常被误解的一点:TS 的类型在编译后会被完全擦除。运行时没有类型,报错只能发生在编译期。理解这点,才能搞清 tsc/tsx 的差异、ESM/CJS 互操作的坑、以及为什么大项目编译慢。
tsc 编译流程:从 .ts 到 .js
tsc 做两件事:类型检查 + 转译(去类型、降语法到 target)。类型信息本身不进产物——你写的 : string 在 .js 里消失得无影无踪。
论为什么"类型错误"不会到运行时
因为类型在编译阶段就被擦掉了,运行时 JS 引擎根本不知道有类型。所以 TS 的保证是编译期的——一旦你用 any/as 绕过,错误就会溜到运行时才爆。这也是为什么 strict + 类型门禁值得投入。
tsx / ts-node:直接跑 TS 不显式编译
开发/脚本时不想先 tsc 再 node,用 tsx(基于 Esbuild,极快)或 ts-node 直接执行 .ts。它们边跑边转译,省一步。
tsx/ts-node 只转译不严格检查,所以"能跑起来"不代表"类型没问题"。生产构建和 CI 必须单独跑 tsc --noEmit 把关。别把"本地 tsx 跑通"当成"类型安全"。
ESM / CJS 互操作:最常见的运行时炸点
Node 同时有 CommonJS(require)和 ES Module(import)。两者混用时要靠 module/moduleResolution 与包的 exports 协调,配错就 ERR_REQUIRE_ESM 或 default is not a function。
| 场景 | 现象 | 解法 |
|---|---|---|
| CJS 包被 ESM import | 需 .default 才是真导出 | 用 import pkg from 'x' |
| ESM 包被 CJS require | ERR_REQUIRE_ESM | 改成 dynamic import() |
| 扩展名省略 | Node 找不到模块 | NodeNext 写全 .js |
类型擦除的真相:运行时没有 type
一个经典误区:以为 interface/type 会在运行时生成校验。不会——它们完全消失。要运行时校验(如接口入参),得用 Zod 这类"带运行时的 schema 库"。
论"编译期类型"与"运行时校验"是两套
TS 管"我写的代码内部一致"(编译期);Zod/接口 schema 管"外部进来的数据可信"(运行时)。边界处(API 入参、表单、配置文件)必须用运行时校验补上 TS 擦除掉的真空。两者配合才是完整安全。
编译性能:project references 与更快的工具链
大项目 tsc 可能几十秒。解法:① project references 把仓库拆成子项目,只编改动的部分;② 用基于 SWC/Esbuild 的检查器(如 tsgo、typescript-go)把类型检查提速数倍;③ 编辑器用 tsc --watch 增量。
默认 tsc 会检查所有 .d.ts(含第三方),大项目很慢。skipLibCheck: true 跳过对声明文件的检查,通常安全且大幅提速——绝大多数项目该开。但别用它掩盖"你自己写的错误 .d.ts"。
typesVersions 与多版本声明分发
一个包可能要同时支持不同 TS/运行时的类型。用 package.json 的 typesVersions 字段,按"导入的路径前缀"指向不同 .d.ts,实现"同一包多套类型"。
| 机制 | 作用 | 何时用 |
|---|---|---|
exports + types | 按入口给出对应声明 | 常规分包类型 |
typesVersions | 按导入路径前缀分流 | 同入口多 TS 版本 |
/// <reference> | 合并多份声明 | 环境声明聚合 |
论类型分发的本质是"让调用方无感"
无论 exports 还是 typesVersions,目标都是:调用方写一句 import,就能自动拿到"适配他环境的正确类型"。库作者把复杂度藏在配置里,用户体验才顺。这也是"类型即契约"在分发层面的体现。
类型驱动开发的取舍:何时该上 TS
TS 不是万能银弹。它的收益随"项目复杂度、协作人数、生命周期"上升而显著;小脚本、一次性原型则不划算。下面是经验判断。
| 场景 | 建议 | 理由 |
|---|---|---|
| 大型长期项目 / 多人协作 | TS 必上 | 类型即文档、重构安全 |
| 库 / 框架 / 工具链 | TS 必上 | 消费者靠类型用对 API |
| 中小业务应用 | 推荐上 | strict 早发现问题 |
| 一次性脚本 / 原型 | 可不上 | 类型成本 > 收益 |
论上 TS 的"隐性成本"要算清
TS 带来:类型标注时间、第三方类型缺失时要补 .d.ts、复杂泛型会拖慢编译与招聘门槛。但它省下的是"运行时才发现的低级 bug"和"改一处牵一身的恐惧"。权衡的结论对绝大多数生产项目是正向的——只是别在写一次性 demo 时也硬套。
TypeScript 的价值在"编译期发现错误、类型即文档、重构安全"。基础是类型推断、字面量类型(as const)、联合/交叉与收窄(typeof/instanceof/in/可辨识联合)、泛型(保留类型信息而非退化成 any)。类型体操用条件类型、infer 提取、映射类型(keyof 遍历)、递归类型、模板字面量类型,业务够用即可、留给库作者。装饰器(TC39 标准 vs experimentalDecorators)适合横切关注点(日志/缓存/鉴权/DI),配 reflect-metadata 做依赖注入。工程上用 tsconfig 的 strict 逼出隐患、paths/moduleResolution 决定模块解析、.d.ts 给无类型 JS 补说明书、优先装 @types 而非 @ts-ignore。框架里 React 用泛型组件+类型守卫+显式 hook 泛型,Vue 用 script setup + defineProps/defineEmits 类型声明。运行时真相:类型在编译后被擦除,tsx 只转译不检查,ESM/CJS 互操作靠 module/exports 协调,运行时校验用 Zod 补齐;大项目用 project references + skipLibCheck 提速。
1.为什么说 any 是"债务"?unknown 和它有什么区别?泛型又为什么比 any 好?
查看答案
any 关闭类型检查且会"传染"相邻表达式,让代码重新变不安全。unknown 同样表示"未知",但使用前必须显式收窄(typeof/instanceof),更安全。泛型在调用点把具体类型接住并保留(如 first<string> 返回 string),类型信息不丢且仍带检查——要"通吃"又不想"失明"用泛型。
2.条件类型的"分发"是什么?为什么有时要用 [T] extends [U] 关掉它?
查看答案
裸类型参数(T extends U)遇到联合类型会"逐成员计算",得到分布的联合结果(如 ToArray<string|number> → string[]|number[])。把 T 包进元组 [T] extends [U] 会关掉分发,当作整体判断。想逐成员变换用裸写,想整体判断加方括号。
3.TS 编译后类型去哪了?为什么"tsx 跑通"不等于"类型正确"?
查看答案
interface/type/注解在编译后被完全擦除,运行时 JS 引擎不知道有类型——TS 的保证只在编译期。tsx/ts-node 只做即时转译、不做完整类型检查,所以能跑起来也可能有类型错误。生产/CI 必须单独跑 tsc --noEmit 把关。
4.TC39 标准装饰器和旧的 experimentalDecorators 为什么不能混用?依赖注入靠什么在运行时拿到类型?
查看答案
两者语法与实现机制不同(签名、元数据来源),同项目混开会编译冲突或行为诡异,老项目保持旧模式、新项目用标准装饰器。运行时依赖注入靠 reflect-metadata:emitDecoratorMetadata 把构造参数类型写进元数据,容器据此自动 new 并注入依赖。
下一步往哪走
路学完 TypeScript 之后
① 串前端:回前端补全页看 React/Vue 怎么用 TS(泛型组件、类型守卫、defineProps),以及类型安全在测试/状态管理里的延伸。
② 看 Node 进阶页:NestJS 大量用装饰器 + 依赖注入 + 严格 tsconfig,是 TS 工程化的旗舰场景。
③ 端到端类型:了解 tRPC / OpenAPI 代码生成(如 openapi-typescript)/ Zod,把前后端类型真正打通,让"接口契约"在编译期可校验。
④ 动手:给一个 JS 项目开 strict 模式,补 .d.ts,接 Zod 做入参校验,体会"编译期+运行时"双层安全。