TypeScript 教程:从类型标注到工程化

一、TypeScript 是什么:类型检查与转译是两件事

先纠正一句最流行的口号。人们说「TypeScript 是 JavaScript 的超集」,这话对了一半:语法上它确实是超集——任何合法的 JS 都是合法的 TS,你只是在 JS 之上加了一层可以写的类型标注。但 TS 不是「多了一套新语法能做的事」,运行时它不提供任何 JS 没有的能力。它做的事情只有一件额外的:在你运行代码之前,多问一句「这个类型对得上吗」。

tsc 到底干了两件事

命令行里敲 tsc,它其实同时在做两件互相独立的事:

  1. 类型检查:读你的 .ts,对照类型规则挑毛病,有问题就打印错误。
  2. 转译(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 帮你把类型收得更窄,窄到具体值。

示例 02:注解语法与类型推断——哪些错会被挑出来在新标签页打开

函数返回值的推断

不写返回类型时,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 的区别,是这一章的核心:

anyunknown
能赋给别的类型吗能(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」从运行时惊喜变成了编译期契约。

示例 03:基础类型与 any / unknown / never 的边界在新标签页打开

四、对象类型与接口: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:该怎么选

两者大部分场景能互换,差别集中在几条:

interfacetype(类型别名)
扩展方式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,这也是「类型层面的空集」的一个来源。

示例 05:联合、交叉与可辨识联合的穷尽检查在新标签页打开

可辨识联合: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、事件处理器)反复用到的写法,也让「传进去的函数长什么样」一目了然。

示例 06:函数类型——参数、返回与推断的边界在新标签页打开

重载:多个签名 + 一个实现

当一个函数「传不同类型进去、返回不同类型出来」,用重载。语法是先列多个对外签名,再写一个对内的实现签名:

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
}

断言函数对调用方是「隐形收窄」:它没有返回值可用,副作用是改变了后续代码里变量的类型。校验外部输入时特别顺手。

示例 07:类型收窄——编译器怎么逐步相信你的判断在新标签页打开

收窄不是运行时保护

最后再敲一次黑板:收窄只影响编译期看法。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 元素硬当某个自定义类型、老库类型写错时——但每次用都该停下来想想:是不是该用校验代替。

示例 08:断言的四种工具与它们的分工在新标签页打开

非空断言 !

后缀 ! 告诉编译器「这个值这里一定不是 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。

示例 09:字面量类型与 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 的全部价值:既复用代码,又不牺牲信息。

示例 11:泛型入门——同一个函数用 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 内置了一堆「拿来就用的类型变换」,统称工具类型。它们让日常业务不用每次手写映射。这一章逐个讲用途,并对每个现场用映射类型手写一遍,让你看清「它不过是前面那些零件的组装」。

示例 13:工具类型逐个拆——用映射类型手写一遍在新标签页打开

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,三条正规出路:

  1. 装社区类型:npm i -D @types/xxx——最常见,多数流行库都有。
  2. 自己写 declare module:在项目的 types/xxx.d.ts 里声明它的形状,哪怕先只写你用到的几个函数。
  3. 临时兜底:declare module 'xxx'(不写内容)把它当 any 放行,或开 allowJs 直接吃它的 JS——但这是把类型关掉,属于过渡不是终点。

顺序优先选 1,其次 2;3 只在时间紧迫时短暂使用,并留待补齐。

十六、tsconfig 全解:编译选项的真实含义

tsconfig.json 是 TS 项目的控制台。这一章不逐条背,而是把最常配错、影响最大的几组讲透:target/lib/module/moduleResolution 的真实差别、strict 家族各自开关、include/exclude/paths/noEmit,以及「noEmit + 打包器」为什么才是现代主流组合。

示例 16:tsconfig 关键选项——改了会影响什么在新标签页打开

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 不是一个开关,而是一揽子——它同时打开:

开关管什么
strictNullChecksnull/undefined 从各类型独立出来(第三章)
noImplicitAny推断不出来的地方不许悄悄变 any,要你显式写
strictFunctionTypes函数参数按逆变检查,函数类型更严格
strictBindCallApplybind/call/apply 的参数类型要正确
strictPropertyInitialization类的非可选属性必须在构造函数里初始化
noImplicitThis不许出现「this 是 any」的函数
useUnknownInCatchVariablescatch 里的错误默认是 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 在真实项目里和各种工具怎么分工配合,再列出那些「知道就躲得过、不知道就踩个够」的坑。它们大多源自第一章的两个事实——类型会被擦除、类型检查与转译互不干涉。

示例 17:常见坑清单——每一条都是真实报错样本在新标签页打开

谁做转译谁做检查:Vite / Vue / React / Node 的分工

不同栈里,「把 TS 变成能跑的 JS」和「检查 TS 类型对不对」这两件事分别由谁干:

场景转译(擦类型、产出)类型检查
Vite(纯 TS)esbuild(极快,不检查类型)需另跑 tsc --noEmit,或靠 IDE
Vue + vue-tscVite/esbuildvue-tsc(模板里的类型也要查)
React + Viteesbuild / SWCtsc --noEmit
Node(ts-node / tsx)ts-node/tsx 即时擦除看配置,默认可能不查
Node 原生 type strippingV8 直接抹类型跑不做检查,纯转译
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 会报错。 先给一张维度对照大表,再逐条展开。

示例 18:TS 与 JS 的区别——同一份逻辑,两个世界在新标签页打开

一张总表:维度 / JavaScript / TypeScript / 一句话备注

维度JavaScriptTypeScript一句话备注
类型系统动态弱类型,运行时才定静态强类型,编译期标注+推断TS 的类型只在编译期存在
变量声明let/const/var同左,可加 : T 注解注解是「可加」不是「必加」
编译与运行引擎直接执行源码需转译成 JS 再执行运行时永远跑的是 JS
工具链引擎/浏览器即可多一层 tsc/打包器/类型检查见第十六、十七章分工
错误暴露时机多在运行时大量前移到编辑/构建期TS 的核心价值所在
重构安全性靠人肉+全局搜类型图兜底,改一处红一片TS 最被低估的收益
文件后缀与模块.js/.mjs.ts/.tsx/.d.tsESM 规则一致,多了类型文件
类与字段标准 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),它就把闸放开、假装一切正常,把问题原样还给运行时。理解「类型擦除」和「检查与转译两件事」,你就拿到了后面所有细节的解释力。

评论

为保障内容质量,评论需站长审核通过后才会展示;提交后没立刻看到属正常,请勿重复提交。

还没有评论,来抢沙发~