一、TypeScript 是什么:类型检查与转译是两件事
先纠正一句最流行的口号。人们说「TypeScript 是 JavaScript 的超集」,这话对了一半:语法上它确实是超集——任何合法的 JS 都是合法的 TS,你只是在 JS 之上加了一层可以写的类型标注。但 TS 不是「多了一套新语法能做的事」,运行时它不提供任何 JS 没有的能力。它做的事情只有一件额外的:在你运行代码之前,多问一句「这个类型对得上吗」。
tsc 到底干了两件事
命令行里敲 tsc,它其实同时在做两件互相独立的事:
- 类型检查:读你的
.ts,对照类型规则挑毛病,有问题就打印错误。 - 转译(emit):把
.ts里的类型全部抹掉,再按target把新语法降级,输出成一份.js。
关键认知是:这两件事的结论彼此不影响。
// 这份代码有类型错误,但它照样会被转译出 .js
let n: number = 'hello' // tsc 报错:Type 'string' is not assignable to type 'number'
console.log(n.toUpperCase()) // 运行时 n 其实是字符串,这行反而能跑
tsc 会一边打印那条错误、一边把 .js 生成出来(除非你开了 noEmitOnError)。也就是说类型报错拦不住转译——这跟编译型语言「语法不过就产不出二进制」的直觉完全不同。理解了这点,后面很多「为什么有红波浪线还能跑起来」的困惑就都通了。
为什么运行时不需要 TypeScript
浏览器和 Node 从来不认识 TypeScript。你发出去的、被执行的,永远是类型被删光之后的 JS。类型只活在你写代码和 tsc/构建工具检查的那几分钟里,从不出现在最终的产物中。这条机制有个正式名字:类型擦除(type erasure)——转译时所有 : number、interface、<T>、as、! 都会被原样撕掉,留下的就是等价 JS。
由此推出三条实践后果,值得现在就记住:
- 类型错了不等于程序出错:
tsc报红,程序可能照跑;tsc全绿,程序也可能崩——因为类型只保证「标注自洽」,不保证「运行时真实」。 - 泛型、接口、类型别名在运行时不存在:你不能
if (x is User)里靠User这个 type 判断,type 是编译期的,instanceof只认 class 这种运行时的东西(第七章展开)。 - 别指望类型能在运行时保护你:外部数据(接口返回、用户输入)永远是「未经检验」的,类型标注只是你对编译器许的愿,编译器不会替你去核实网络回来的 JSON(第十五、十七章会反复强调)。
那 TS 到底图什么
既然运行时无非就是 JS,为什么要多这一层?答案全在写代码的那段时间里:
| 你得到的 | 从哪来 |
|---|---|
| 自动补全 | 编译器知道 user. 后面能接什么 |
| 提前暴露错误 | 拼错属性名、少传参数、null 直接 .map,写的时候就红 |
| 安全重构 | 改一个字段名,所有用到它的地方全变红,一个不漏 |
| 类型即文档 | 函数签名就是最准的说明书,不会像注释那样过期 |
| 接住第三方库 | 有 .d.ts 声明的库,你在编辑器里就能看见它的全部 API |
一句话:TS 把「运行时才炸的 bug」往前挪到「编辑时就红」。它买的是确定性,不是性能,也不是新能力。
本章没有示例可点,因为这一章讲的是「转译」这个概念本身,而转译发生在构建阶段。从第二章起,凡是「点得动、看得出区别」的主题都会配可运行示例。
二、第一个类型错误:注解语法 : T 与类型推断
TS 最核心的动作就一个:给一个值「说出它的类型」。写 : T 叫类型注解(annotation),但你会发现——大多数时候你根本不写它,因为 TS 会自己猜,这个猜叫类型推断(inference)。这门语言的体验好不好,八成取决于你会不会跟推断打交道。
注解语法长什么样
类型跟在冒号后面,出现在四个高频位置:
let count: number = 0 // 变量:值前面
function add(a: number, b: number): number { // 参数:名字后面;返回值:括号后面
return a + b
}
const users: string[] = ['a', 'b'] // 数组:元素类型后面加 []
参数必须逐个标注,function add(a, b: number) 里的 a 不会自动继承 b 的类型,它会退回隐式 any(noImplicitAny 开着的严格模式下这里就会报错)。返回值可以不写让编译器推断,但导出的公共函数最好显式写——这样改内部实现时调用方不受影响,接口是一份契约。
为什么有推断你还写注解
因为推断是有方向、有极限的,注解是你对编译器的一次「纠偏」:
let x = 'hello' // 推断成 string(注意:不是字面量类型,见第五章)
const y = 'hello' // 推断成 'hello'(字面量窄化,const 更精确)
function parse(input: string) { // 参数不标就报错,所以必须写
return JSON.parse(input) // 返回值被推断成 any(JSON.parse 的返回是 any)
}
JSON.parse 返回 any——这就是「any 顺着返回值泄漏」的源头之一(第十七章专门讲)。你在这里给它补一个返回注解 : User,就把不确定性挡在了函数边界内。
let 推断成联合类型
TS 对 let 有个聪明的默认:如果声明时给了初值、之后又可能改成别的类型,它会推断成一个能容纳所有写入值的联合类型,而不是死盯初值:
let id = 1 // 推断 number;随后 id = 'a' 会报错
let flag = true // 推断 boolean
// 但若你压根没给初值,它就退化成 any 或需要注解
let name // 无初值 + 严格模式:隐式 any,报错
let name2: string // 补注解即可
const 的字面量窄化
const 因为不能再改,编译器就敢把它的类型收成那个具体的字面量,而不是宽泛的 string/number:
const dir = 'up' // 类型是 'up'(字面量类型),不是 string
let dir2 = 'up' // 类型是 string
const num = 42 // 类型是 42
这条差异在第五章「可辨识联合」和第九章「字面量类型」里会起到关键作用,先埋个印象:const 帮你把类型收得更窄,窄到具体值。
函数返回值的推断
不写返回类型时,TS 看 return 后面到底吐什么来反推。推断通常很准,但有个坑:递归函数不写返回类型可能推错(编译器还没算完函数体就遇到自己调用自己,会要求你显式标注)。公共 API 上老老实实写返回类型,既是自我约束,也是给下游的一份稳定契约。
三、基础类型:string / number / boolean、数组与元组、枚举,以及 any / unknown / never
这一章把 TS 里最常被写错、也最常被误解的几种类型过一遍。JS 教程第三章已经讲清楚了 JS 世界里的八种值,所以这里只讲类型系统那一层:TS 怎么描述它们,以及三个特殊类型 any/unknown/never 到底各管什么。
三种原始类型没有惊喜
string、number、boolean 就是 JS 的字符串、数字、布尔——注意 TS 的 number 和 JS 一样不区分整数与浮点,没有 int/float/double 这些。别下意识写 let age: int,那是 Java/C# 的手感,TS 里只有 number。bigint 和 symbol 也各有对应类型,用得少。
数组与元组
数组用 T[] 或 Array<T> 写,两种等价,Array<T> 在类型复杂时更好读:
let list: number[] = [1, 2, 3]
let list2: Array<number> = [1, 2, 3] // 完全等价
let pair: [string, number] = ['id', 1] // 元组:位置和类型都固定
pair = [1, 'id'] // ✗ 顺序错了也报错
元组(tuple)是「长度和各位置类型都写死」的数组,常用于函数一次返回多个值、或表示定长的键值对。长度可变时还有可选元组元素 [string, number?] 和展开元组 [...T[], string],用得不多,知道有即可。
枚举:enum 与它的运行时代价
枚举是 TS 里少见的真的会往 JS 里生成代码的特性(第七章会看到别的类型都被擦除,enum 恰恰不会)。它给一组常量起名字:
enum Direction { Up = 0, Down = 1, Left = 2, Right = 3 }
const d = Direction.Up // 0
Direction[0] // 'Up'——反向映射:数字也能查回名字
正因为 enum 会编译成一个真实的对象,它有运行时成本:体积、反查、还有 const enum 那种「编译期直接内联成数字、不留对象」的特例(第十七章会讲 const enum 在打包工具下的一系列坑)。实践中很多团队宁可用「联合字面量 + as const 对象」替代 enum,因为后者零运行时开销、还能享受字面量窄化:
const Direction = { Up: 0, Down: 1 } as const
type Direction = (typeof Direction)[keyof typeof Direction] // 0 | 1
any:逃生舱,不是「未知类型」
any 的意思是「关掉这个值上的所有类型检查」。你想让编译器闭嘴时,写 any 就行——但代价是:从这一刻起,这个值身上发生的一切错误,编译器都不再提醒你。用了 any,你实际上是把这块代码降级回了纯 JS。
let x: any = 'text'
x = 42
x.toExponential(2) // 合法!any 上什么成员都允许
x.somethingNotThere // 也不报错——这正是危险之处
any 只该用在「真的还不知道、且急着跑起来」的临时场合,以及迁移老 JS 代码的过渡期。它是逃生舱,不该是默认选择。
unknown:安全的任意值
unknown 才是「我不知道这是什么类型」的正确表达。它和 any 的区别,是这一章的核心:
any | unknown | |
|---|---|---|
| 能赋给别的类型吗 | 能(any 赋值到哪都不报错,还会传染) | 不能(必须先收窄) |
| 能直接调用/取属性吗 | 能(不检查) | 不能(报错,逼你判断) |
| 定位 | 逃生舱:关掉检查 | 类型安全的 any |
let u: unknown = 'hello'
u.toUpperCase() // ✗ 报错:unknown 上什么都不能直接做
const s: string = u // ✗ 报错:unknown 不能赋给 string
if (typeof u === 'string') {
u.toUpperCase() // ✓ 收窄后才能用
}
const n: number = u as number // 也能用断言强行说它是 number(自担风险)
任何值都能赋给 unknown,但 unknown 想再往下走得先「验明正身」。TS 4.4 起,catch (err) 里的 err 默认就是 unknown 而不是 any,逼你去判断捕获到的到底是什么——这是把不确定性挡在使用点之前的设计哲学。记住口诀:any 是让编译器别管,unknown 是让编译器管但先别信。
never:不可能到达
never 是「没有任何值」的类型,出现在两种地方:
function fail(msg: string): never { // 永不正常返回:要么抛错要么死循环
throw new Error(msg)
}
function loop(): never { while (true) {} }
type Empty = number & string // 交集为空 → never
never 最重要的用法在第五章——穷尽检查:当联合类型的所有分支都被处理完,剩下的分支应当是 never,一旦哪天加了新分支没处理,编译器就会在这里报错。它是 TS 里少数「用来验证你写全了」的机制。
null / undefined 与 void
void 表示「没有值」,主要用作函数返回类型,告诉编译器「别指望这里返回东西」:
function log(msg: string): void {
console.log(msg) // 没有 return
}
let v: void = undefined // void 类型的变量只能装 undefined(严格模式下)
null 和 undefined 在关掉 strictNullChecks 时可以被赋给任何类型,这是老代码里无数 Cannot read property of undefined 的根源。打开 strictNullChecks(属于 strict 家族,第六章、十六章详谈)后,null/undefined 从各类型里独立出来:string 不再包含 null,你要表达「可能是空」必须显式写 string | null。这是 TS 最有价值的一条开关——它把「这里会不会是 null」从运行时惊喜变成了编译期契约。
四、对象类型与接口:type 与 interface 的差别
JS 教程第十章讲了对象在运行时怎么写;这一章讲 TS 怎么给对象「画形状」。核心是两种描述结构的方式 type 和 interface,以及围绕对象的四种修饰:可选、只读、索引签名、函数与对象混合。
先看清:结构相同即类型相同
TS 是结构化类型(structural typing)——名字不重要,形状重要。两个接口哪怕毫不相干、没继承关系,只要字段对得上,一个就能当另一个用:
interface Point { x: number; y: number }
const p: Point = { x: 1, y: 2, z: 3 } // ✗ 报错(多余属性检查,见下)
function use(pt: { x: number }) { /* 只用 x */ }
use(p) // ✓ 只要 p 至少有 x 就行,不在乎它还有 y
这点和第 17 章「假兼容」的坑直接相关:结构化类型很灵活,但会让你以为「这两个类型不同所以不通用」,实际上编译器只看形状。
type 与 interface:该怎么选
两者大部分场景能互换,差别集中在几条:
interface | type(类型别名) | |
|---|---|---|
| 扩展方式 | extends,可以继承多个 | 交叉 &,也能拼 |
| 同名声明 | 会自动合并(声明合并) | 同名再定义会报错 |
| 能表达的范畴 | 只能描述对象/函数/构造这类「形状」 | 几乎什么都能:联合、元组、映射类型、条件类型 |
| 报错信息 | 通常更简洁 | 复杂组合时更易读(保留了名字) |
| 惯例 | 描述对象契约、公共 API | 需要联合/工具/高级类型时用 |
一句话经验:描述一个对象的形状优先 interface;一旦需要联合、映射、条件这类「类型的运算」,只能用 type。团队里两者都允许即可,不必教条。
type ID = string | number // 联合——interface 做不到
type Pair = [number, number] // 元组——type 更顺手
type Flags = { a: boolean } & { b: boolean } // 交叉,等价于 {a:boolean;b:boolean}
可选属性 ?
问号表示「这个键可以不存在」,一旦标了 ?,它的类型就自动变成 T | undefined:
interface User { name: string; age?: number }
const u: User = { name: '阿蓝' } // 合法,age 省略
u.age // number | undefined
u.age.toFixed(2) // ✗ 报错:可能是 undefined
u.age?.toFixed(2) // ✓ 可选链(JS 教程第四章的语法,类型上同样有效)
if (u.age !== undefined) u.age.toFixed(2) // ✓ 收窄后能用
只读 readonly 与 as const
readonly 让属性只能初始化时赋值一次,之后再改就报错:
interface Config { readonly api: string }
const c: Config = { api: '/v1' }
c.api = '/v2' // ✗ 只读属性不能改
readonly 是编译期约束,运行时值仍可改(除非 Object.freeze)。第九章会看到 as const 把整个对象所有属性一次性都标成 readonly 且窄化成字面量。
索引签名:描述「键不固定的对象」
当对象的键你说不全(比如一个「字符串映射到数字」的字典),用索引签名:
interface StringNum { [key: string]: number }
const map: StringNum = { a: 1, b: 2 }
map.c = 3 // 合法:任意字符串键 → number
map.d = 'x' // ✗ 值必须是 number
// 数字索引:数组本质上就是 { [index: number]: T } 的特例
数字键的索引签名比字符串键更严格:[key: number]: T 只约束数字下标访问。
函数类型与可调用对象混合
一个东西既能被调用、又带属性,用「调用签名 + 属性」混合写法:
interface Counter {
(start: number): string // 调用签名:它是个函数
interval: number // 同时又带属性
reset(): void
}
function getCounter(): Counter {
const c = ((start: number) => String(start)) as Counter
c.interval = 10
c.reset = () => {}
return c
}
这类写法在描述「函数上挂了些元信息」的库 API 时很常见。函数本身的类型还可以直接写成 type Fn = (a: number) => number,第六章细讲函数类型。
五、联合类型、交叉类型与可辨识联合
联合和交叉是 TS 类型系统里最像「代数运算」的两个东西:联合是「或」,交叉是「且」。掌握它们,尤其是联合的收窄玩法,是从「会用 TS」到「用得好 TS」的分水岭。
联合类型 A | B:值要么是 A 要么是 B
type ID = string | number
function render(id: ID) {
id.toString() // ✓ string 和 number 都有 toString,取交集成员
id.length // ✗ number 上没有 length,联合里不能直接用某一分支独有的成员
id.toUpperCase() // ✗ 同理,未收窄前只能用「所有分支都保证」的成员
}
联合类型在没收窄之前,只允许访问所有分支共有的成员(是各分支成员的交集)。要访问某一分支独有的,得先把它收窄成那个分支——第七章会系统讲收窄,这里先看联合的基本脾气。
给联合赋值时反过来:任何一支的值都能赋进去,id = 'a'、id = 1 都合法;但从联合取用时受限。这就是它的核心张力。
交叉类型 A & B:同时满足两边
type HasName = { name: string }
type HasAge = { age: number }
type Person = HasName & HasAge // { name: string; age: number }
const p: Person = { name: '阿蓝', age: 18 }
交叉是把多个形状「叠加」成一个更具体的形状,字段全都要满足。接口之间用 extends、type 之间用 &,效果对对象而言基本一致。当两支有同名不同类型字段时会得到 never,这也是「类型层面的空集」的一个来源。
可辨识联合:TS 里最好用的模式
如果联合的每一支都带一个字面量类型的「标签」字段,TS 就能凭这个标签把分支自动区分开——这叫 discriminated union,是整个 TS 里最被推崇的建模手法:
type Shape =
| { kind: 'circle'; r: number }
| { kind: 'rect'; w: number; h: number }
| { kind: 'triangle'; base: number; height: number }
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.r ** 2 // 这支里 s 收窄成 circle
case 'rect': return s.w * s.h // 只能访问 w/h
case 'triangle': return (s.base * s.height) / 2 // 只能访问 base/height
}
}
关键在于 kind 是字面量类型('circle' 而非 'string')。在 switch (s.kind) 的每个 case 里,s 会自动收窄到对应那一支,于是 s.r、s.w 这些专属字段都变得类型安全,不需要任何断言。没有标签字段(kind: string)这一整套就全塌了——这就是第九章「把值当类型用」和第二章 const 窄化真正兑现价值的地方。
穷尽检查:用 never 兜底
上面的 area 还差一步:万一以后给 Shape 加了 case 'polygon' 却忘了在 switch 里处理怎么办?用 never 兜底:
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.r ** 2
case 'rect': return s.w * s.h
case 'triangle': return (s.base * s.height) / 2
default:
const _exhaustive: never = s // 若上面没覆盖全,s 还有剩余类型,赋给 never 就报错
return _exhaustive
}
}
处理完全部分支后,default 里的 s 类型应当是 never(所有可能都被 case 吃掉了),赋给一个 never 变量正好成立。一旦你漏了某一支,s 就不是 never 而是那个没处理的具体分支类型,赋值报错——你等于给编译器留了个哨兵,专门盯着「未来加了新分支但你忘了改这里」。业务里凡是 switch 一个可辨识联合,都建议加上这条 never 兜底。
六、函数类型:参数、可选、剩余、重载与 this
函数是 TS 里类型信息最密集的地方,也是注解写得最多的一处。JS 教程第六章讲过 JS 函数的行为(默认值、剩余参数、闭包捕获),这里不重复那些运行时语义,只讲类型怎么描述它们,以及几个 TS 特有的点:重载签名、void 与 undefined 作为返回类型的差别。
可选参数与默认值
参数上加 ? 表示可省略,它的类型随即变成 T | undefined;给了默认值的参数自动是可选的,而且类型从默认值推断,通常不用再标:
function greet(name: string, title?: string) {
// title: string | undefined
return title ? `${title} ${name}` : name
}
function greet2(name: string, title = '先生') { // title 推断为 string,且可选
return `${title} ${name}`
}
注意一个顺序规则:可选参数和带默认值的参数要排在必选参数后面,否则调用时没法靠位置区分。
剩余参数
剩余参数在类型上写成一个数组类型,泛型写法更常见:
function sum(...nums: number[]): number {
return nums.reduce((a, b) => a + b, 0)
}
function join(sep: string, ...parts: string[]) { /* parts: string[] */ }
元组也能描述「固定前缀 + 可变后缀」的签名:(...args: [string, ...number[]]) => void。
函数类型表达式
函数本身可以当成一个类型来命名和传递,用 (参数: 类型) => 返回:
type Comparator = (a: string, b: string) => number
function sortBy(arr: string[], cmp: Comparator) { /* ... */ }
const byLen: Comparator = (a, b) => a.length - b.length // cmp 的类型已知,a/b 自动推断
把函数签名抽成 type 别名,是回调型 API(Array.prototype.map 的 callback、事件处理器)反复用到的写法,也让「传进去的函数长什么样」一目了然。
重载:多个签名 + 一个实现
当一个函数「传不同类型进去、返回不同类型出来」,用重载。语法是先列多个对外签名,再写一个对内的实现签名:
function pick(x: string): string
function pick(x: number): number
function pick(x: string | number): string | number { // 实现签名不对外暴露
return x
}
pick('a') // 返回 string
pick(1) // 返回 number
要点:实现签名必须能兼容所有对外签名,且调用方只能看见对外的那几个签名,看不见实现签名。外部调用如果落不进任何对外签名,就报错。重载写起来啰嗦,现代 TS 里很多场景可以用「泛型 + 条件返回类型」(第十一、十四章)替代,更简洁也更不容易出错。
箭头函数与 this
箭头函数没有自己的 this,它捕获定义时外层的 this。这在类型上表现为:普通函数的 this 默认是 any(严格模式下需要 ThisType 或显式 this: T 参数),而箭头函数的 this 从上下文来,无需注解:
const timer = {
seconds: 0,
start() {
setInterval(function () {
this.seconds++ // ✗ 这里 this 是全局/window,不是 timer——经典坑
}, 1000)
setInterval(() => {
this.seconds++ // ✓ 箭头函数捕获 timer 的 this,正确
}, 1000)
}
}
普通函数若要显式约束 this,在参数表最前面加一个 this: T(它不是真参数,调用时不占位)。这是 TS 提供的一个「只给 this 打类型」的特殊语法。
void 与 undefined 作返回类型的差别
两者细微不同,但会咬人:void 表示「调用方不关心返回值」,TS 对返回 void 的函数很宽容——它可以返回任何东西,反正调用方承诺不读(比如 arr.forEach(() => x()),回调返回什么都行)。而 undefined 作返回类型则严格要求真的只返回 undefined。所以当你想表达「随便返回啥但我不看」时,用 void;想表达「它必须什么都不返回」时,才用 undefined。
七、类型收窄:编译器怎么一步步相信你的判断
如果说这门课只能精读一章,就是这一章。收窄(narrowing)是 TS 类型系统的发动机:类型不会自己变精确,是你在代码里做的判断,让编译器在某个分支内把一个宽类型「收窄」成具体的窄类型。第五章的可辨识联合、第三章的 unknown 使用点,全都靠收窄落地。
什么是收窄:一个直观例子
function len(x: string | number) {
// 这里 x 是 string | number,length/toFixed 都不能直接用
if (typeof x === 'string') {
return x.length // ✓ 这个分支里 x 被收窄成 string
}
return x.toFixed(2) // ✓ 走到 else,x 收窄成 number
}
typeof 这个判断本身在 JS 里就有,TS 只是读懂了它的含义:在 === 'string' 为真的分支里,把 x 的类型从联合里剩下的分支收窄。收窄不改变运行时的值,只改变编译器在这一段里对它的看法。
typeof 收窄
支持的字面量:"string"、"number"、"boolean"、"function"、"object"、"symbol"、"undefined",以及 "bigint"。注意 typeof null === 'object' 这条 JS 老 bug 也影响收窄——判对象为真还要再 x !== null。
in 收窄
用一个「只有某支才有」的属性名来区分,靠 in:
type Fish = { swim: () => void }
type Bird = { fly: () => void }
function move(a: Fish | Bird) {
if ('fly' in a) a.fly() // ✓ 'fly' in a 为真时 a 收窄成 Bird
else a.swim()
}
in 收窄的正是那种「没设计标签字段、但各支属性不同」的场合——不过更好的做法通常是回去把它改成可辨识联合。
instanceof 收窄
对 class 实例,用 instanceof 判断,收窄到对应类。因为 class 是运行时真实存在的,所以这条能用;而纯 interface/type 运行时不存在,instanceof 帮不上忙(第七章的这条限制,正是第一章「类型擦除」的直接后果)。
function describe(e: Error | Date) {
if (e instanceof Error) return e.message // 收窄成 Error
return e.getTime() // 收窄成 Date
}
真值收窄与相等收窄
if (x)、x && ...、x === value、x !== value 这些都能收窄。真值收窄会把 0、''、null、undefined、NaN 这些假值从类型里剔除;相等收窄则配合字面量类型精确命中:
function f(s?: string) {
if (s) {
s.toUpperCase() // ✓ 排除了 undefined 和空串(string 仍在)
}
}
type Status = 'a' | 'b' | 'c'
function g(x: Status) {
if (x === 'a') { /* x: 'a' */ }
else { /* x: 'b' | 'c' */ } // 排除 'a' 后剩两支
}
真值收窄有个陷阱:0 和 '' 也会被一起剔掉,如果你本意只是「判空」,用 x !== undefined 更精确,别误用 if (x)。
类型谓词 x is T:把你的判断经验交给编译器
标准判断不够用时,你写一个返回 boolean 的函数,但用 x is T 告诉编译器「返回真即说明 x 是 T」——这就是类型谓词(type predicate),用户自定义收窄的核心工具:
function isString(x: unknown): x is string {
return typeof x === 'string'
}
function use(v: unknown) {
if (isString(v)) {
v.toUpperCase() // ✓ 因为 isString 声明了 x is string
}
}
// 配合 filter 极其好用:过滤后的数组类型会自动窄化
const nums = ['a', 1, 'b', 2].filter(isString) // nums: string[]
is 前面必须是函数参数本身(x is T),TS 只信任「对这个变量做的判断」。这个写法把「我确信它是这个类型」的经验,安全地注入编译器,而不必用 as 硬断言。
asserts x is T:断言函数
类型谓词的「加强版」——它不返回布尔,而是不满足就抛错,从而在调用点之后把变量收窄。适合做参数校验:
function assertIsNumber(v: unknown): asserts v is number {
if (typeof v !== 'number') throw new Error('not a number')
}
function compute(v: unknown) {
assertIsNumber(v)
v.toFixed(2) // ✓ 调用之后,编译器认定 v 是 number
}
断言函数对调用方是「隐形收窄」:它没有返回值可用,副作用是改变了后续代码里变量的类型。校验外部输入时特别顺手。
收窄不是运行时保护
最后再敲一次黑板:收窄只影响编译期看法。typeof x === 'string' 这个分支运行时确实会执行对应 JS,但那是 JS 的判断在起作用,不是类型系统。外部数据你没法靠类型「声明」它是 string——必须靠 typeof/in/校验库在运行时真的验一遍(第十五、十七章)。收窄是「你在代码里做出判断,编译器据此更新它的看法」这个协作过程,两半缺一不可。
八、断言:as、双重断言、非空断言,以及 satisfies 与 as const
断言是 TS 里最强大也最容易被滥用的一组工具。它的本质一句话:你对编译器说「相信我,这个值其实是某个类型」,编译器照办,且不再检查——出了错你自己担。本章把四种断言工具各自的分工讲清楚。
as T:普通断言
const el = document.getElementById('app') as HTMLElement
const input = el.querySelector('#name') as HTMLInputElement
const data = response.body as User
as 用来在「编译器知道的类型」和「你知道的实际类型」之间搭桥,常见于 DOM 查询(querySelector 默认返回宽泛的 Element)、以及类型断言从 unknown 收窄。但 as 不做运行时转换,x as number 不会把字符串转成数字,它只是改编译器看法。
这里引用本课程反复强调的一句话,正好给断言下个定义:断言是你写给编译器的情书——你告诉它该怎么想,它全盘接受,不做任何核实。 情书写得越满,运行时被打脸越疼。所以能用收窄(is 谓词)解决的地方,就别用 as。
双重断言 as unknown as T
直接从 A 断言到八竿子打不着的 B 会被拒(「两种类型重叠不足」),此时先绕到 unknown 再断言就通得了:
const el = document as unknown as HTMLElement // 直断报错,借 unknown 中转
const v = someValue as unknown as TargetType
双重断言是「我最清楚,编译器你别拦」的终极表达,几乎放弃了所有安全检查。它合理的时候很少——比如把 DOM 元素硬当某个自定义类型、老库类型写错时——但每次用都该停下来想想:是不是该用校验代替。
非空断言 !
后缀 ! 告诉编译器「这个值这里一定不是 null/undefined」,把 T | undefined 收成 T:
function f(s?: string) {
const len = s!.length // 你保证 s 一定有值;真给了 undefined 就运行时崩
}
const el = document.getElementById('root')! // 断言这个 id 一定存在
! 是「我检查过了,编译器别念叨」的速记。风险显而易见:如果你没真检查过,s!.length 就是 Cannot read properties of undefined。它是 strictNullChecks 下的止痛药,不该变成主食——能用可选链 ?.、能用收窄分支解决的,都别用 !。
as const:把字面量彻底钉死
as const 不是断言类型,而是断言「别再宽化了」:加在字面量后面,所有属性变 readonly,所有值收成最具体的字面量类型:
const a = { dir: 'up' } // dir: string
const b = { dir: 'up' } as const // dir: 'up'(且整个对象只读)
const dirs = ['up', 'down'] as const // readonly ['up', 'down'],可当联合 'up'|'down' 用
第九章会专门讲它解决「字面量宽化」的价值,这里先记住:as const 帮你把「值」冻成「类型」。
satisfies:校验形状但不丢窄化
TS 4.9 新增的 satisfies 解决一个 as 解决不好的两难。你想让一个值符合某类型、又不想因此丢掉它精确的字面量推断:
type Route = string | (() => string)
const routes = {
home: '/',
about: () => '/about',
} satisfies Record<string, Route> // 校验:值必须是 Route
routes.home // 类型是 '/'(字面量保住),而不是被 route 的注解宽化
routes.about() // 能调用,且知道存在
对比 const routes: Record<string, Route> = {...}——那样 routes.home 会变成宽泛的 string | (() => string),丢掉「它其实就是 '/'」的信息。satisfies 的分工就是:做「类型检查」但保留「更精确的推断结果」,还能在使用点发现多余/缺失属性。三者记住一句话——as 改编译器看法、as const 钉死字面量、satisfies 校验又不丢精度。
九、字面量类型与 as const:把「值」当「类型」用
这一章解释一件很多人后知后觉的事:在 TS 里,一个具体的值本身就是一种类型。'up' 不只是字符串的一个实例,它还是一个「只可能是 up 这个字符串」的类型。理解这点,第五、七章的字面量窄化、switch 穷尽才真正说得通。
字面量类型家族
字符串、数字、布尔、null、undefined 都能各自成为「只包含这一个值」的字面量类型:
let a: 'hello' = 'hello' // 只能是 hello
let n: 42 = 42 // 只能是 42
let t: true = true // 只能是 true(注意不是 boolean)
它们的价值全在联合里:把一堆允许值并成一个联合类型,等于给参数列了白名单:
type Dir = 'up' | 'down' | 'left' | 'right'
function move(d: Dir) { /* 只认这四个 */ }
move('up') // ✓
move('diagonal') // ✗ 不在白名单里,直接报错
这比用 string 宽泛接参、运行时再 if 判断安全太多——白名单错误在写的时候就暴露了。
字面量宽化问题
TS 默认会把字面量「往宽里推」:let x = 'up' 里的 x 被推断成 string 而不是 'up',因为 let 意味着以后可能改成别的字符串。这个「从具体值放宽到所属基本类型」就叫宽化(widening)。多数时候它合理,但当你想要那个具体类型时就成了障碍。
三条把类型「钉在字面量」的手段:显式注解 const d: Dir = 'up';const 声明(第二章讲过的窄化);as const。
as const 的三种落点
const s = 'hi' as const // 'hi'
const arr = [1, 2, 3] as const // readonly [1, 2, 3](元组 + 只读)
const obj = { x: 1, y: 'a' } as const // { readonly x: 1; readonly y: 'a' }
对象用了 as const,所有属性都变 readonly 且值收成字面量;数组变成只读元组。它常和 satisfies、可辨识联合、枚举替代方案搭配——第五章那个「联合字面量替代 enum」的写法就靠 as const。
把值当类型用:typeof 类型查询
typeof x 出现在类型位置时,是「取 x 的类型」,配合索引访问能从一组常量里提炼出联合类型:
const CONFIG = { api: '/v1', retry: 3 } as const
type ConfigKey = keyof typeof CONFIG // 'api' | 'retry'
type RetryVal = (typeof CONFIG)['retry'] // 3
这是「值」和「类型」两个世界之间的一扇后门:常量对象一处维护,它的键、它的值类型都能反查出来,不用手写两遍。
const 类型参数(简提)
TS 5.0 引入 <const T>,让泛型函数在接收实参时自动对它做 as const 式窄化,免去调用方手打:
function keep<const T>(x: T): T { return x }
keep({ a: 1 }) // 推断为 { readonly a: 1 },而不是 { a: number }
日常用得不多,知道它存在、能理解库作者为什么有时用它即可,不深入。
十、类:修饰符、参数属性、抽象与真正的私有
JS 教程第十一章讲过 class 的运行时行为(extends、super、方法、静态成员)。这一章只看 TS 在类上加了哪些类型层面的东西:可见性修饰符、参数属性、implements、抽象类,以及「类型的类」和「实例的类」这一组容易混的概念。
public / private / protected
TS 给成员加了三种可见性,只在编译期生效——擦除后 JS 里根本没有私有:
class Account {
public owner: string // public 可省略,默认就是
private balance: number // 只能在类内部访问
protected level: number // 类内部 + 子类可访问
constructor(owner: string) {
this.owner = owner
this.balance = 0
this.level = 1
}
deposit(n: number) { this.balance += n }
getBalance() { return this.balance }
}
new Account('a').balance // ✗ 报错:private 不能外部访问
要认清:private 挡的是编译器,不是铁了心的运行时攻击——account['balance'] 用字符串索引能绕开(TS 层面也会红,但值确实读得到)。它是「契约」不是「保险箱」。真要运行时私有见下面的 #。
参数属性:构造函数的样板消除
在 constructor 参数前面直接加修饰符和 readonly,TS 会自动帮你声明字段并赋值:
class Point {
constructor(
public readonly x: number,
public readonly y: number
) {} // 不用手写 this.x = x 了
}
这一条能把「声明字段 + 构造函数赋值」的重复代码砍掉大半,日常写数据类时高频使用。注意它是 TS 独有语法,擦除后仍会还原成 this.x = x 的普通赋值。
static 与抽象类
static 是 JS 就有的(挂在类本身而非实例),TS 只是给静态成员也加上类型注解。abstract 是纯 TS 的:抽象类不能直接 new,它的抽象方法没有实现、必须由子类补全,是「强制子类实现某些方法」的手段:
abstract class Shape {
abstract area(): number // 只声明不实现
describe() { return `area=${this.area()}` } // 可以有普通实现
}
class Square extends Shape {
constructor(private side: number) { super() }
area() { return this.side ** 2 } // 必须实现,否则报错
}
new Shape() // ✗ 抽象类不能实例化
implements:声明「我满足某个契约」
implements 让类显式承诺它符合某个接口。它只检查「类有没有接口的成员」,不改变类型本身:
interface Drivable { steer(angle: number): void }
class Car implements Drivable {
steer(angle: number) { /* ... */ } // 漏了 steer 就编译报错
}
一个常见误解:implements 不是运行时校验,也不像 Java 那样参与多态判定——它只是编译期的一张检查清单。忘了实现某方法时报错,仅此而已。
真正的私有:# 字段
ES 标准的私有字段 #name 才是运行时真正私有的,外部任何方式都摸不到:
class Vault {
#secret = 'abc' // 运行时私有,编译后是真私有
private hint = 'x' // 仅编译期私有
reveal() { return this.#secret }
}
new Vault().#secret // ✗ 语法直接报错
(new Vault() as any).secret // 也拿不到,运行时确实没有这个公开属性
# 与 private 别混用概念:private 是 TS 的类型层约束(会被擦除),# 是 JS 的运行层约束(会保留)。老打包目标不支持 # 时 TS 会把它降级成 WeakMap。
访问器 get / set
访问器和 JS 一样,TS 只是给读写分别加类型:
class Temp {
#c = 0
get celsius() { return this.#c }
set celsius(v: number) { this.#c = v }
}
只写 get 不写 set 会读得到、写不进(严格模式下赋值报错),等价于一个「计算得出的只读属性」。
类的类型 vs 实例的类型
一个 class 名字在 TS 里同时是两个类型:作为「实例的形状」和作为「构造器本身」。区分它们是理解 new 和泛型约束的关键:
class User { name = '' }
let u: User = new User() // User = 实例类型
let C: typeof User = User // typeof User = 构造器类型(能 new 的那个)
let C2: new () => User = User // 也可以直接写构造签名
User 指实例的形状,typeof User 指「这个类本身」。写「接收一个类、内部 new 它」的泛型函数时(第十一章),这个区别会立刻用上。
十一、泛型入门:为什么需要泛型而不是 any
泛型是 TS 的灵魂。这一章先建立直觉:泛型是「类型的参数」——函数接收值作为参数,泛型函数额外接收「类型」作为参数,让返回类型能跟着输入类型的形状变化。先看不用泛型、用 any 的失败,再看泛型怎么救场。
同一个例子:先写成 any,再写成泛型
需求:写一个函数,返回数组的第一个元素。用 any:
function firstAny(arr: any[]): any {
return arr[0]
}
const x = firstAny([1, 2, 3]) // x: any —— 丢了元素类型!
x.toFixed(2) // 不报错,但可能运行时崩
firstAny(['a']).toUpperCase() // 也不能安全地知道是 string
any 版本致命缺陷:进去的时候你知道是 number[],出来的时候啥都不知道了——信息在 any 处漏光了。现在用泛型:
function first<T>(arr: T[]): T {
return arr[0]
}
const a = first([1, 2, 3]) // T 被推断成 number,a: number
const b = first(['x', 'y']) // T 推断成 string,b: string
a.toFixed(2) // ✓ 知道是 number
b.toUpperCase() // ✓ 知道是 string
<T> 是一个「类型变量」:调用时 TS 看实参 number[],把 T 填成 number,于是返回值也就是 number。类型进了函数又活着出来了——这就是泛型相对 any 的全部价值:既复用代码,又不牺牲信息。
泛型约束 extends:给类型变量划范围
<T> 天真的地方是「T 上什么成员都不能访问」(因为编译器不知道 T 长啥样)。想访问某成员,就得用 extends 约束它至少具备什么:
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b // ✓ 约束保证 a/b 一定有 length
}
longest('abc', 'a') // string 有 length,通过
longest([1, 2], []) // 数组也有 length,通过
longest(1, 2) // ✗ number 没有 length,被约束挡住
T extends U 读作「T 至少是 U 的形状」。约束既让函数内部能安全访问成员,又在调用点挡住了不合规格的实参——这是泛型「有边界」的两面。
默认类型参数
<T = 某类型> 给类型变量一个默认,调用时不显式传就用默认:
function box<T = string>(v: T): { value: T } { return { value: v } }
box('a') // T = string
box(42) // T = number(实参优先于默认)
box<string>(42) // 显式指定 T = string,此时 42 反而报错
默认类型参数常用于「给个合理缺省、但允许覆盖」的通用容器和工具函数。
什么时候用泛型,什么时候别
经验法则:当「输入类型」和「输出类型」之间存在形状依赖时,用泛型。像 first、map、identity、Record<K,V> 这种「你给啥类型、我按同样形状返回」的,泛型最合适。如果函数的返回类型和入参类型毫无关系,或者你只想要「多种固定情况之一」,用联合类型(第五章)而不是泛型——别为了看起来高级而把简单联合写成复杂泛型,可读性会付代价。
十二、泛型进阶:接口、类、keyof、索引访问与映射类型入门
泛型不只活在函数上。这一章把泛型铺到接口、类型别名、类上,并介绍三件「类型的运算」工具:keyof、索引访问 T[K]、以及映射类型的雏形——它们是下一章工具类型的地基。
泛型接口与泛型类型别名
把类型变量放进接口/别名的尖括号,就得到「可参数化的形状」:
interface Box<T> { value: T }
interface Pair<A, B> { first: A; second: B }
let b: Box<number> = { value: 1 }
let p: Pair<string, boolean> = { first: 'a', second: true }
type Result<T> = { ok: true; data: T } | { ok: false; error: string }
function unwrap<T>(r: Result<T>): T | undefined {
return r.ok ? r.data : undefined
}
泛型接口是 API 建模的主力:Result<T>、Promise<T>、Array<T>、React 的 Props<T> 全是它。
泛型类
类同样能带类型参数,实例化时确定:
class Stack<T> {
private items: T[] = []
push(v: T) { this.items.push(v) }
pop(): T | undefined { return this.items.pop() }
}
const s = new Stack<number>() // 只能 push number
上一节 typeof User 那对概念在这里闭环:写一个「接收某个类的实例并返回同类」的泛型方法时,靠 new () => T 这样的构造签名约束,TS 才知道你能 new 它、且 new 出来的就是 T。
keyof:取一个类型所有键的联合
keyof T 返回 T 全部属性名组成的字符串/数字联合:
interface User { name: string; age: number }
type K = keyof User // 'name' | 'age'
function get<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
get({ name: 'a', age: 1 }, 'name') // ✓ 返回类型是 string
get({ name: 'a' }, 'nope') // ✗ 'nope' 不在 keyof 里
keyof + extends keyof T 约束,让「按名字安全取属性」成为可能——key 只能是真实存在的键名,返回类型还能精确到那个键的值类型。
索引访问类型 T[K]
T[K] 取出「T 里键 K 对应的值类型」,和 keyof 是天造地设的一对:
type UserAge = User['age'] // number
type First = [string, number][0] // string(元组按位置取)
它把「属性名」升级成「类型层面的可计算量」:配合泛型 T[K] 就是上面 get 函数精确返回类型的秘密。工具类型 Pick/Record/ReturnType 全靠这套索引访问搭起来。
映射类型入门:用键的联合造新类型
映射类型是「遍历一个键的联合,逐个生成属性」的语法,一句话就能看出威力:
type Mutable<T> = {
-readonly [K in keyof T]: T[K] // 把每个属性去掉 readonly
}
type Readonly2<T> = {
readonly [K in keyof T]: T[K] // 给每个属性加 readonly
}
[K in keyof T] 读作「对 T 的每个键 K」,后面是这些键在新类型里的形状。加上修饰符开关 readonly/?(还能用 +/- 增删),你能「把一个类型的所有属性整体变换一遍」。下一章那些内置工具类型,本质全是这里这种映射类型手写的结果——把这里看明白,第十三章就不是背 API 而是懂原理。
十三、工具类型逐个拆:它们其实都是映射类型
TS 内置了一堆「拿来就用的类型变换」,统称工具类型。它们让日常业务不用每次手写映射。这一章逐个讲用途,并对每个现场用映射类型手写一遍,让你看清「它不过是前面那些零件的组装」。
Partial<T> / Required<T> / Readonly<T>
给所有属性加 ? / 去掉 ? / 加 readonly,用于「更新用的部分字段」「把可选补齐」等:
interface User { id: number; name: string }
type Upd = Partial<User> // { id?: number; name?: string }
function updateUser(u: User, patch: Partial<User>): User { return { ...u, ...patch } }
type P = Partial<{ a: number; b: string }> // { a?: number; b?: string }
// 手写一遍:
type MyPartial<T> = { [K in keyof T]?: T[K] } // [K in keyof T]? 就是给每键加 ?
type MyRequired<T> = { [K in keyof T]-?: T[K] } // -? 去掉可选
type MyReadonly<T> = { readonly [K in keyof T]: T[K] } // 加 readonly
-?、-readonly 里的减号是「移除该修饰符」,与不写符号(默认加)相对。
Pick<T, K> / Omit<T, K>
Pick 从 T 里挑几个键,Omit 从 T 里去掉几个键:
interface Full { id: number; name: string; pwd: string }
type Public = Pick<Full, 'id' | 'name'> // { id: number; name: string }
type NoId = Omit<Full, 'id'> // { name: string; pwd: string }
type MyPick<T, K extends keyof T> = { [P in K]: T[P] }
// Omit 依赖第十四章的条件类型 Exclude,这里先给成品:
type MyOmit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>
Pick 的映射源是 K(要保留的键),每键的值取自 T[P]——一句话讲完它的原理。Omit 是「先算出去掉 K 后剩下的键,再 Pick」。
Exclude<T, U> / Extract<T, U> / NonNullable<T>
作用在联合类型上的三个筛选器:
type A = Exclude<'a' | 'b' | 'c', 'a'> // 'b' | 'c'(从 T 里剔除 assignable to U 的)
type B = Extract<'a' | 'b' | number, string> // 'a' | 'b'(只留 assignable to string 的)
type C = NonNullable<string | null | undefined> // string(去掉 null/undefined)
type MyExclude<T, U> = T extends U ? never : T // 条件类型:命中就丢
type MyExtract<T, U> = T extends U ? T : never
type MyNonNullable<T> = T extends null | undefined ? never : T
extends 用在裸类型参数上会逐个分发到联合的每个成员(第十四章细讲分发条件类型)——所以 Exclude 能对联合每一支分别判断。这三个是组合其它工具类型的常用零件。
ReturnType / Parameters / ConstructorParameters
从一个函数类型里「抠」出返回类型 / 参数元组:
function make(a: number, b: string): { id: number } { return { id: a } }
type R = ReturnType<typeof make> // { id: number }
type P = Parameters<typeof make> // [number, string]
type MyReturnType<T extends (...a: any) => any> = T extends (...a: any) => infer R ? R : never
type MyParameters<T extends (...a: any) => any> = T extends (...a: infer P) => any ? P : never
ReturnType 的写法先用了 infer(第十四章)——「在匹配函数形状时,把返回位置的那段抽出来叫 R」。这里记住结论:这几个工具是「把函数类型当数据来拆零件」。
Awaited<T>:扒开 Promise 的外壳
Awaited<T> 递归地剥掉 Promise/thenable 外层,拿到最终的值类型,专门配合异步:
type V = Awaited<Promise<Promise<string>>> // string(反复剥到底)
type MyAwaited<T> =
T extends PromiseLike<infer U>
? T extends PromiseLike<T> ? never : MyAwaited<U> // 递归剥
: T
注意它处理的是「类型」不是「值」:给一个 Promise<number> 的类型,得到 number 类型。取实际异步函数返回值的类型时非常顺手。
Record<K, V>:键类型到值类型的字典
Record<K, V> = 「K 里每个键都映射到 V」,比手写索引签名更能表达「键是有限的几个」:
type Pages = Record<'home' | 'about', string> // { home: string; about: string }
type Dict = Record<string, number> // 等价于 { [k: string]: number }
type MyRecord<K extends keyof any, V> = { [P in K]: V }
Record 的映射源是 K(键联合),每个键的值统一是 V。它常用于给枚举/联合键做完整的映射表,编译器会检查你有没有把每个键都填上。
选用的分寸
工具类型好用,但别堆砌。链上七八层 Omit<Pick<Partial<...>>> 会让报错信息变成天书、也让同事读不动。先写朴素接口,需要变换时再上工具类型;能用一句话讲清它做了什么,才值得这么写。
十四、条件类型与 infer、模板字面量类型:类型体操入门
这一章进入 TS 的「类型也能做运算」的深水区。日常业务里,你大概率不需要写这么深——绝大多数页面代码停在第十三章就绰绰有余。但读懂库作者写的类型、以及享受工具类型带来的推断,需要这里的知识。只讲够用的几个概念,配两三个例子,不追求把体操做到极限。
条件类型:类型层面的三元
T extends U ? X : Y 是「如果 T 能赋给 U 就取 X 否则取 Y」,一个写在类型位置的三元表达式:
type IsNumber<T> = T extends number ? true : false
type A = IsNumber<42> // true
type B = IsNumber<'x'> // false
关键特性是分发(distributive):当 T 是裸类型参数且是联合,条件类型会对联合的每个成员分别计算再并起来。这就是 Exclude 能「逐个筛掉」的原理:('a'|'b'|'c') extends 'a' ? never : T 会拆成对 'a'、'b'、'c' 各自判断,命中 'a' 变 never 被丢,剩下的并回联合。想关掉分发,把 T 包进元组 [T] extends [U] 即可。
infer:在匹配中「捕获」一个类型
infer R 写在条件类型的「被匹配的形状」里,意思是「这一段随它是什么,抽出来命名叫 R」。前面 ReturnType、Awaited 都靠它:
type First<T> = T extends [infer F, ...any[]] ? F : never
type F = First<[string, number]> // string
type Flatten<T> = T extends Array<infer U> ? U : T
type E = Flatten<number[]> // number
infer 让你从「一个复杂的类型」里按形状「解构」出想要的局部,是类型体操里最常用的捕获手段。
模板字面量类型:在类型层面拼字符串
TS 4.1 起,字符串字面量类型支持模板拼接,用 `${A}` 形式:
type World = 'world'
type Greeting = `hello ${World}` // 'hello world'
type EventName<T extends string> = `on${Capitalize<T>}`
type Click = EventName<'click'> // 'onClick'
type Style = `${number}px` | `${number}%`
function setSize(s: Style) {}
setSize('10px') // ✓
setSize('big') // ✗ 不符合模板
模板字面量类型最实用的一面:给「有规律的事件名、路由、CSS 单位」这类字符串建立模式约束,让拼错在编译期就报错。用 Capitalize/Lowercase/Uppercase 等字符串工具类型(TS 内置)还能做大小写变换。
分寸再强调一遍
上面这些组合起来能做「类型层面的编程」,理论上能写很复杂的推断。但在业务代码里写它们要极其克制:库的 .d.ts、通用的类型工具值得精雕;一个具体页面的 props 用不着条件类型套娃。读懂 > 会写 > 会炫。本章示例点到为止,能认出「哦这是在用 infer 抽返回类型」就够了。
十五、模块与声明文件:ESM、import type、.d.ts 与给无类型库写声明
JS 教程第二十章讲过 ESM 的 import/export 语法。这一章讲 TS 在模块之上加的东西:import type、被擦除后的类型去哪了、.d.ts 声明文件是什么、怎么用 declare 给一个没有类型的 JS 库「补上」类型,以及遇到「找不到类型声明」时的三条出路。
ESM 与 import type
TS 里导入分两种:导值(运行时真要有这个东西)和导类型(编译期用完就擦除,运行时根本不需要它存在)。把「只用来做类型」的导入写成 import type,是明确的信号也是优化:
import type { User } from './types' // 纯类型,擦除后这条 import 整个消失
import { getUser } from './api' // 值,运行时真的要用
为什么区分?因为擦除。User 是 type,运行时不存在,若写成普通 import { User } 再只在类型位置用它,打包器可能把整模块拽进产物却找不到 User(isolatedModules 下会直接报错,见第十七章)。import type 让「这是类型、别当值处理」一目了然,也让单文件转译(各文件独立擦除)能安全进行。
.d.ts 是什么
.d.ts 是只有类型、没有实现的声明文件。它是库对外发布的「类型说明书」:编译器读它知道 API 长什么样,运行时它一行代码都不产出。你自己的 .ts 编译时会附带产出 .d.ts(declaration: true),第三方库的类型则来自 npm 上的包或 @types/*。
// some-lib/index.d.ts —— 只声明,不实现
export interface Options { timeout?: number }
export function request(url: string, opts?: Options): Promise<Response>
declare 与 declare module
declare 关键字用于「告诉编译器:外面有这个东西,你别生成也别检查,信我」。给一个全局脚本变量、一段内联的 JS 库打类型,就靠它:
// 描述一个挂在 window 上的全局(例如某统计脚本)
declare const _hmt: string[][]
// 给一个「其实有、但没类型」的模块声明形状
declare module 'legacy-lib' {
export function doThing(x: number): void
}
declare module '名字' 是给「JS 库但没带 .d.ts」补类型的标准姿势——你在项目里建一个 .d.ts,用 declare module 声明这个包对外暴露的形状,之后 import ... from 'legacy-lib' 就有类型了。
@types 与包里的 types 字段
一个包怎么被找到类型?两条路:
- 包自己内置:
package.json里types/typings字段指向它的.d.ts(现代 TS 库普遍这么发布)。 - 社区补:DefinitelyTyped 维护、发布成
@types/包名(如@types/lodash、@types/react),npm i -D @types/xxx即可。
编译器解析 import 'lodash' 时,先看包自身 types 字段,没有再找 node_modules/@types/lodash。
找不到类型声明的三种解法
import 'xxx' 报「找不到模块声明文件」(TS7016 之类)时,别急着 // @ts-ignore,三条正规出路:
- 装社区类型:
npm i -D @types/xxx——最常见,多数流行库都有。 - 自己写
declare module:在项目的types/xxx.d.ts里声明它的形状,哪怕先只写你用到的几个函数。 - 临时兜底:
declare module 'xxx'(不写内容)把它当any放行,或开allowJs直接吃它的 JS——但这是把类型关掉,属于过渡不是终点。
顺序优先选 1,其次 2;3 只在时间紧迫时短暂使用,并留待补齐。
十六、tsconfig 全解:编译选项的真实含义
tsconfig.json 是 TS 项目的控制台。这一章不逐条背,而是把最常配错、影响最大的几组讲透:target/lib/module/moduleResolution 的真实差别、strict 家族各自开关、include/exclude/paths/noEmit,以及「noEmit + 打包器」为什么才是现代主流组合。
target / lib:转译产物 vs 允许用的 API
这两个最容易混,分清就赢了一半:
target:决定语法降级到哪一档 ES 版本。target: "es2020"意味着 TS 会把更新的语法(比如可选链、逻辑赋值)降级成 ES2020 能跑的 JS。它管的是「语法」。lib:决定编译器认为环境里有哪些内置 API。用Promise、Array.prototype.includes、fetch、Map这些,都要lib里有对应声明才不报「找不到」。
关键陷阱:target 不会帮你 polyfill 任何 API。你 target: "es5" 但代码里用了 Promise,编译不报错(因为 lib 默认按 target 推),产物里也不会凭空出现 Promise——运行时 Promise is not defined 照崩。lib 是「我信任运行环境有这些东西」的声明,不是「请帮我把缺的补上」。
{
"compilerOptions": {
"target": "ES2020", // 语法降到 ES2020
"lib": ["ES2020", "DOM", "DOM.Iterable"] // 允许用这些环境 API
}
}
module / moduleResolution:产物用什么模块格式 vs 怎么找模块
又是一对常混项:
module:TS 输出的 JS 用哪种模块语法——ESNext(import/export原样保留)、CommonJS(转成require)等。现代项目基本ESNext,交给打包器处理。moduleResolution:import './x'时 TS 用什么规则去找x的类型文件——bundler(打包器友好)、node16/nodenext(按 Node 的 ESM/CJS 规则)、node(老经典 Node)。
module 与 moduleResolution 要配套(比如 nodenext 解析要求 module: nodenext),否则会出现「运行时能 import、类型却找不到」的错位。纯打包器项目用 bundler,写 Node 服务端用 nodenext,是当前两条主流路线。
strict 家族:一次打开一串安全闸
"strict": true 不是一个开关,而是一揽子——它同时打开:
| 开关 | 管什么 |
|---|---|
strictNullChecks | null/undefined 从各类型独立出来(第三章) |
noImplicitAny | 推断不出来的地方不许悄悄变 any,要你显式写 |
strictFunctionTypes | 函数参数按逆变检查,函数类型更严格 |
strictBindCallApply | bind/call/apply 的参数类型要正确 |
strictPropertyInitialization | 类的非可选属性必须在构造函数里初始化 |
noImplicitThis | 不许出现「this 是 any」的函数 |
useUnknownInCatchVariables | catch 里的错误默认是 unknown |
alwaysStrict | 每份产物顶部加 "use strict" |
新项目必开 strict。老项目从 false 往 true 迁会一夜冒出成百上千条错,常见过渡法是先把 noImplicitAny、strictNullChecks 逐个单独打开、逐步修,最后收敛到 strict: true。
include / exclude / paths
include/exclude:告诉编译器「哪些文件参与类型检查」。默认包含项目下所有.ts;exclude里默认已有node_modules、outDir。范围外的文件即使被 import 也照样解析,但不会被当成「入口」单独检查。paths:模块路径别名,把@/utils/foo映射到src/utils/foo。注意paths只影响编译器的类型解析,不影响运行时——运行时还得靠打包器(Vite 的resolve.alias)或 Node 的exports真正解析。这是「TS 里能用别名、上线就找不到模块」的经典原因:两边都要配。
noEmit 与增量构建、项目引用
noEmit: true:只做类型检查、不产出 JS。现代项目里这几乎是标配——见下一节。incremental/tsBuildInfoFile:把上次检查的缓存写盘,第二次只重查变动文件,加速。- 项目引用(composite + references):把大型仓库拆成多个
tsconfig,各自独立、按依赖增量重建,monorepo 常用。
为什么 noEmit + 打包器才是常见组合
核心答案回到第一章:类型检查和转译是两件事。现代工具链把这两件事分工给不同工具:
- 打包器(Vite/esbuild/SWC/webpack)负责转译:快,专门把 TS 擦成 JS、把模块打包,但它不做完整的跨文件类型检查。
tsc --noEmit或 IDE / ESLint 负责类型检查:慢但准确,专挑类型错误,不负责产生产物。
所以典型配置是 noEmit: true 让 tsc 只当「类型守门员」,转译交给打包器。这也解释了为什么「类型明明报错、vite dev 还照常跑」——因为跑起来的是打包器那份不检查类型的转译。真正把关靠 CI 里那条 tsc --noEmit。第十七章会把这个分工讲得更完整。
十七、工程实践与常见坑:谁转译谁检查,以及那份坑清单
这一章把前面所有原理收到「工程现场」。先讲清楚 TS 在真实项目里和各种工具怎么分工配合,再列出那些「知道就躲得过、不知道就踩个够」的坑。它们大多源自第一章的两个事实——类型会被擦除、类型检查与转译互不干涉。
谁做转译谁做检查:Vite / Vue / React / Node 的分工
不同栈里,「把 TS 变成能跑的 JS」和「检查 TS 类型对不对」这两件事分别由谁干:
| 场景 | 转译(擦类型、产出) | 类型检查 |
|---|---|---|
| Vite(纯 TS) | esbuild(极快,不检查类型) | 需另跑 tsc --noEmit,或靠 IDE |
| Vue + vue-tsc | Vite/esbuild | vue-tsc(模板里的类型也要查) |
| React + Vite | esbuild / SWC | tsc --noEmit |
| Node(ts-node / tsx) | ts-node/tsx 即时擦除 | 看配置,默认可能不查 |
| Node 原生 type stripping | V8 直接抹类型跑 | 不做检查,纯转译 |
tsc 直接构建 | tsc 转译 + 检查一起做 | 就是它 |
结论只有一句:跑得快的工具(esbuild/SWC/V8)通常只转译不检查,所以「本地能跑」不等于「类型没问题」——完整检查必须由 tsc --noEmit(CI 里)或 IDE 提供。这也是 Vue 项目要专门用 vue-tsc 的原因:模板里的表达式普通 tsc 看不到。
isolatedModules 为什么需要
打包器是一个文件一个文件独立转译的(它拿不到全局类型图),因此某些「需要跨文件才知道」的写法它处理不了。isolatedModules: true 让 tsc 提前拒掉这些写法,保证你的代码「每个文件都能被单独安全转译」。典型要求:
- 每个文件都得是真模块(有
import/export),别写会被当成全局脚本的文件。 - 重导出类型要用
export type { T }而非export { T }。 - 别用
const enum跨文件、别用只有namespace里的运行时值等。
现代框架脚手架几乎都默认开它——它不是让你更严,而是让「tsc 检查通过」和「打包器转译行为」对齐,避免出现「tsc 说没问题、esbuild 转出来不对」的裂缝。
ESLint 与类型规则
ESLint 负责 lint(代码风格、可疑写法、一致性),@typescript-eslint 让它认识 TS 语法。其中一部分规则需要类型信息(如「禁止在非 thenable 上 await」「no-misused-promises」),要在 ESLint 配置里提供 parserOptions.project 指向 tsconfig。分工:tsc --noEmit 管「类型对不对」,ESLint 管「写法好不好」,两者互补,不重复。常见踩坑是类型感知规则让 lint 变慢——把重规则集中到 CI,本地跑轻量集是通行做法。
坑一:any 顺着返回值泄漏
any 不只影响你标它的那一处,它会沿着数据流传染:一个返回 any 的调用,你把结果再传给别的运算,那条链上的类型全塌成 any。JSON.parse、部分老库、(e: any) 事件都是源头。对策:在函数边界加返回注解(const u: User = JSON.parse(s) as User 至少标明你要什么),开 noImplicitAny 拦住隐式漏点,遇到 any 尽量转成 unknown 再收窄(第三章)。
坑二:结构化类型导致的「假兼容」
第四章讲过 TS 看形状不看名字,副作用就是两个语义完全不同、形状恰好一致的类型可以互相赋值,编译器一声不吭:
type UserID = string
type OrderID = string
function get(u: UserID) {}
get(orderId) // ✓ 编译通过!但语义上是把订单号当用户号传了
两者都是 string,TS 不拦。想制造「名义类型」的区分感,给类型加一个独有的品牌字段(branded type):type UserID = string & { readonly __brand: 'User' }。否则别指望 TS 帮你区分「同为 string 的不同含义」。
坑三:枚举的运行时成本与 const enum
第三章说过 enum 会真实生成对象。普通 enum 的问题是有运行时体积、跨文件在 isolatedModules 下受限;const enum 想省体积(编译期直接把 Direction.Up 内联成 0,不生成对象),但在打包/分包场景 const enum 坑更多:内联要求编译器看到定义,esbuild/swc 各自独立转译时可能把 const enum 当普通 enum 处理,行为不一致。这也是大量团队弃用 enum、改用「as const 对象 + 联合字面量」的原因——零运行时、无内联歧义、还能窄化(第九章)。
坑四:import type 与循环引用
类型之间会互相引用形成环(A 的类型用到 B,B 又用到 A)。纯类型循环对 tsc 无害(编译期解析、运行时无物),但一旦有人用值形式 import { SomeType } 引入了运行时 import,就可能造出真实的模块循环、触发初始化顺序 bug。用 import type(第十五章)把「只是类型」的依赖和「真要在运行时取值」的依赖分开,循环就只存在于编译期、不会带进运行时。
坑五:过度断言
第八章那句「断言是写给编译器的情书」在此回收。满屏 as、!、as unknown as,等于亲手把类型系统关掉却以为还开着——编译器不再报错,但运行时报错照旧。能用收窄(is 谓词)就别用 as;能用 satisfies 就别用 as;! 只在确实无法表达的非空场景用。断言是最后手段不是默认工具。
坑六:把 unknown 用 as 直接吃掉
面对 unknown(外部数据、catch 错误),正确姿势是收窄验证(typeof/in/校验库)。最省事也最危险的做法是 const u = err as MyType 一步到「我以为的类型」——这等于绕过了 unknown 特意设置的那道「先验再用」的闸,把不安全又塞回了系统。unknown 是 any 的安全版(第三章),别用 as 把它退化回 any 的效果。
十八、TypeScript 与 JavaScript 的区别
全课收口,回答三个终极问题:「TS 是 JS 的超集」对不对、TS 会不会让程序变快、哪些 JS 代码原样搬进 TS 会报错。 先给一张维度对照大表,再逐条展开。
一张总表:维度 / JavaScript / TypeScript / 一句话备注
| 维度 | JavaScript | TypeScript | 一句话备注 |
|---|---|---|---|
| 类型系统 | 动态弱类型,运行时才定 | 静态强类型,编译期标注+推断 | TS 的类型只在编译期存在 |
| 变量声明 | let/const/var | 同左,可加 : T 注解 | 注解是「可加」不是「必加」 |
| 编译与运行 | 引擎直接执行源码 | 需转译成 JS 再执行 | 运行时永远跑的是 JS |
| 工具链 | 引擎/浏览器即可 | 多一层 tsc/打包器/类型检查 | 见第十六、十七章分工 |
| 错误暴露时机 | 多在运行时 | 大量前移到编辑/构建期 | TS 的核心价值所在 |
| 重构安全性 | 靠人肉+全局搜 | 类型图兜底,改一处红一片 | TS 最被低估的收益 |
| 文件后缀与模块 | .js/.mjs | .ts/.tsx/.d.ts | ESM 规则一致,多了类型文件 |
| 类与字段 | 标准 class、# 私有 | 加修饰符/抽象/参数属性 | private 是编译期,# 是运行期 |
| 运行时能力 | 完整 JS 引擎能力 | 与 JS 完全相同,无增减 | 类型不带来任何新运行时能力 |
| 生态与学习成本 | 上手快、心智负担低 | 有学习曲线、类型体操的成本 | 换确定性要付出复杂度 |
| 可混合性 | 独立存在 | 可与 JS 共存、逐步迁移 | .ts 能 import .js(配 allowJs) |
| 渐进采纳 | 无此概念 | 可从「几乎不写类型」起步 | checkJs 关闭时 TS 项目也能纯放 JS |
下面逐条把关键的几行讲透。
TS 是不是 JS 的超集——对,但只对了一半
严格地说,语法上 TS ⊇ JS:任何合法 JS 文件改名 .ts 都能被 TS 解析(JS 是 TS 的子集)。但「超集」这个词让人以为「TS = JS 的全部行为 + 额外的东西」,这有误导性:TS 不新增任何运行时行为,它新增的是编译期的一层检查。所以更准确的表述是:TS 是「JS + 一套会被擦除的静态类型语法 + 一个会在运行前挑毛病的检查器」。
还有一个反直觉的事实(也是第三个问题的答案):并非所有 JS 都能原样塞进 TS 且不报错。因为 TS 默认开 strict、开 noImplicitAny,纯 JS 里那些「隐式 any、可能为 null 就直接用、参数没传全」的写法,在 TS 里会变成编译错误。也就是说:JS 是 TS 语法上的子集,但一段典型的裸 JS 放进严格配置的 TS 里常常通不过类型检查。
TS 会不会让程序跑得更快——不会
程序实际执行的是擦除类型后的 JS,跟直接写 JS 的最终产物基本等价,运行时性能没有提升。恰恰相反,多出来的 tsc/类型检查是构建期的开销,让「改代码到能跑」这一环变慢(这也是把转译交给 esbuild、检查交给 tsc 的分工动机)。TS 换来的是「人的效率」——少 debug、少回归、重构敢下手——而不是「机器执行」的效率。谁指望加类型让接口变快,那是要失望的。
哪些 JS 代码原样放进 TS 会报错
把这三类裸 JS 丢进 strict 的 TS,立刻红给你看,最能说明「类型检查改的是编译期,不是运行期」:
// ① 隐式 any:参数没类型,noImplicitAny 直接拦
function add(a, b) { return a + b } // ✗ a、b 隐式 any
// ② 可能为 null 却不判断:strictNullChecks 拦
function greet(u) { return u.name.toUpperCase() } // 若 u: { name?: string } 则 name 可能 undefined
// ③ 给 const 重新赋值 / 用了未声明的变量——这些 JS 运行时也炸,但 TS 提前在编辑期就标红
const x = 1; x = 2 // ✗ 对 const 赋值
y = 10 // ✗ 找不到 y(除非声明或挂全局)
反过来,这些是「TS 有、JS 完全没有」的纯类型语法:: 类型、interface、type、<T> 泛型、enum、as/satisfies/! 断言、declare。它们写出来 JS 根本不认,也正是转译时被擦掉的部分。
一句话收尾
TS 的全部故事,第一章就讲完了:它把「运行时的不确定」变成「编辑时的红波浪线」,代价是多一套工具链和一层要理解的类型规则。学 TS 不是学一门新语言,而是学如何把你的意图,用类型这套语言讲给编译器听——讲对了,它在运行前就替你把关;讲歪了(滥用 any/as),它就把闸放开、假装一切正常,把问题原样还给运行时。理解「类型擦除」和「检查与转译两件事」,你就拿到了后面所有细节的解释力。