写TypeScript类型系统总结的文章太多了,但大部分都是照着官方文档抄一遍,看完就忘。这次我把这些年实际项目里用到的、面试里问到的、源码里见到的类型知识全部串了一遍,整理成一份可以直接复制使用的手册。不管你是刚接触TS的新手,还是写了好几年类型的老手,这份总结里应该都有你能直接拿去用的东西。
1. 类型体系的全貌:先看清整张地图再出发
很多人学TypeScript最大的问题是零散地记语法,今天学个interface明天学个泛型,结果遇到复杂类型就卡壳。我个人建议先花半小时把TS类型体系的整体结构摸清楚,后面所有细节都能挂到这张地图上。
1.1 类型的本质:编译期的约束与集合论
TypeScript的类型系统本质上是一套“集合系统”。每个类型描述的是一组可能的值的集合,比如string是所有字符串的集合,number是所有数字的集合,而string | number是这两者的并集。
这套集合论思维极其重要,因为后面所有高级玩法——交叉类型、联合类型、条件类型——本质上都是集合运算。我把类型之间的关系概括为下面几个层次:
- 顶层类型:
unknown和any。unknown是所有类型的父类型,any则直接关闭了类型检查。 - 底部类型:
never。空集,没有任何值可以赋值给它。 - 基础类型:
string、number、boolean、null、undefined、symbol、bigint。 - 复合类型:数组、元组、对象、函数、class实例。
- 字面量类型:
'click'、42、true这种具体值本身作为类型。 - 泛型系统:把类型参数化,相当于“类型的函数”。
- 高级类型工具:映射类型、条件类型、模板字面量类型这些用类型操作类型的语法。
1.2 类型推导与类型推断
TS编译器会在绝大多数场景自动推断类型,不需要我们手动标注。看这个简单例子:
let count = 42; // 推导为 number const name = 'Alice'; // 推导为 'Alice'(字面量类型,因为const不可变) let items = [1, 2, 3]; // 推导为 number[]不过有几类场景必须显式标注。第一是函数参数,TS不会根据函数内部逻辑反推参数类型;第二是函数返回类型,虽然TS能推断,但为了可读性和防止意外改变,我建议复杂函数都写返回类型;第三是那些“初始化为空”的变量,比如let config = {};后面再给对象添加属性就会报错,这种应该先定义接口再标注。
2. 基础类型与字面量类型:地基里的隐藏细节
这一节看起来基础,但我敢说很多人对unknown和any的区别、字面量类型拓宽的坑、以及symbol类型的特殊用法并没有完全吃透。实际项目中这些细节出问题的频率非常高。
2.1 unknown vs any:安全派和躺平派
any意味着“不做任何检查”,可以把任意值赋给任意类型,也能调用任意方法。这相当于把TS降级成了JS。unknown则是“未知但安全”的类型,它同样能接收任意值,但在你确认类型之前不能使用它。
let data: unknown = fetchData(); // data.name; // 报错:对象的类型为 'unknown'。 // 需要先收窄 if (typeof data === 'object' && data !== null) { // 再经过一些断言才能访问属性 }我接手过的老项目里很多any滥用,后来规范改为“能用unknown就不用any”,凡是从外部API拿来的数据一律标成unknown,强制在入口处做类型收窄。实测这个改动让运行时错误明显减少,原因很简单:类型收窄的过程强迫你思考数据真实结构。
2.2 字面量类型与拓宽(Literal Widening)
字符串字面量类型是TS里使用频率极高的特性,三种写法效果完全不同:
let event1: 'click' = 'click'; // 明确标注,类型就是 'click' const event2 = 'click'; // const推导为 'click' let event3 = 'click'; // let推导为 string(拓宽了)如果你希望let声明的变量保持字面量类型,必须显式标注类型。这个细节在做配置对象时极其常用:
let mode: 'development' | 'production'; mode = 'development'; // OK mode = 'test'; // 报错2.3 never类型不是“永不执行”,而是“空集合”
never最常见的使用场景有两个。第一是穷尽性检查,在switch的default分支里声明变量为never,一旦有人给联合类型新增成员忘了添加对应case,编译器就会报错。
type Action = { type: 'INCREMENT' } | { type: 'DECREMENT' }; function reducer(action: Action) { switch (action.type) { case 'INCREMENT': return 1; case 'DECREMENT': return -1; default: { const exhaustiveCheck: never = action; return 0; } } }第二个场景是条件类型的“否则分支”,后面讲条件类型时会重点展开。
3. 对象类型与接口:interface和type到底怎么选
这是一个没有标准答案但每个团队都要面对的问题。先说结论:我个人的统一规范是“公开API用interface,复杂组合和工具类型用type”。但这个结论背后是有具体理由的。
3.1 interface的核心能力:声明合并与继承
interface有两个type没有的特性:声明合并和天然的继承语法。声明合并的意思是同名的interface会自动合并,这在扩展第三方库类型时特别好用:
interface Window { myCustomField: string; } // 不报错,自动合并到了全局Window上继承语法比交叉类型更直观,报错信息也更友好:
interface Animal { name: string; } interface Dog extends Animal { breed: string; }3.2 type别名的独有本领:联合与映射
type可以通过&模拟继承,但它的真正优势在于能表示interface无法表示的东西——联合类型、元组、基本类型别名、条件类型等:
type ID = string | number; type Point = [number, number]; type Result<T> = { success: boolean; data: T }; type Handler = (event: MouseEvent) => void;3.3 可选属性、readonly与索引签名
对象类型里三个高频细节值得单独说。第一个是可选属性attr?: string,它的类型实际上是string | undefined,检查时要用??而不是||去兜底,否则空字符串也会被错误替换。第二个是readonly修饰符,注意它只约束编译期,运行时不生效,无法阻止对象引用内部被修改,深冻结还是要用Object.freeze配合递归。第三是索引签名,写动态对象的隐患很多:
interface StringArray { [index: number]: string; } interface Dict { [key: string]: number; }如果对象里既要有明确属性又要允许额外键,最好这样写:
interface Config { mode: 'dev' | 'prod'; [key: string]: unknown; // 明确索引签名的值为unknown }4. 函数类型与this:容易被忽略的“调用上下文”
函数类型不仅是(args) => return这么简单,重载、参数类型收窄、this参数这些细节在实际工程中都是硬骨头。这里我把函数相关的类型体系完整过一遍。
4.1 函数类型表达式、调用签名与构造签名
三种表达函数类型的方式:
// 方式一:函数类型表达式 type Fn = (x: number, y: number) => number; // 方式二:对象内的调用签名(可以附带属性) interface FnWithProps { (x: number): number; version: string; } // 方式三:构造签名(表示可new) interface DateConstructor { new (): Date; }4.2 函数重载:重载列表在前,实现签名在后
TS的重载本质上是“声明多个调用方式”,而不是像Java那样写多个函数体。拿实际例子说话:
function pick<T>(obj: T, key: keyof T): T[keyof T]; function pick(obj: Record<string, unknown>, key: string): unknown; function pick(obj: any, key: string) { return obj[key]; }重载顺序需要注意:越具体的重载放在越前面,否则后面的会被前面的覆盖或者被TS忽略。这个坑我踩过,把string类型的重载放在'a' | 'b'字面量重载前面,结果调用传入'a'时匹配到了宽泛的签名,返回类型就丢了精度。
4.3 剩余参数与元组类型
剩余参数配上元组类型,能写出很精确的debounce和curry类型:
function debounce<Args extends any[], F extends (...args: Args) => void>( fn: F, delay: number ) { let timer: number; return function (...args: Args) { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; }这里的Args extends any[]是唯一需要允许任意参数列表的方法,虽然any[]看起来不优雅,但在泛型约束中它是合法且常用的。
4.4 this的显式标注
在class里我们靠this推断就够了,但独立函数里的this经常出问题。TS允许在参数列表第一个位置声明this的类型:
interface User { id: number; name: string; } function getUserInfo(this: User) { return `${this.id}: ${this.name}`; } const u = { id: 1, name: 'Alice' }; getUserInfo.call(u); // OK getUserInfo.call({ id: 2, name: 'Bob' }); // OK注意this不是一个真实参数,它只用于编译期检查,运行时JS会忽略它。在写Vue Options API、事件监听器回调时,这个特性格外有用。
5. 泛型系统:TS类型体系中的“引擎”
如果只能选一个主题深入学,我建议把泛型吃透。泛型用好了,复杂类型工具信手拈来;用不好,写出来的类型全是any和重复代码。
5.1 泛型的基本约束与默认值
泛型可以看作“类型层面的函数”,用<>定义参数,用extends做约束,用=给默认值:
function clone<T extends object>(source: T): T { return { ...source }; } type Response<T = unknown> = { code: number; data: T; };5.2 keyof、索引访问类型与类型映射
keyof返回一个类型的键组成的联合类型,配合索引访问类型T[K]可以做很多有用的工具:
type User = { id: number; name: string; email: string }; type UserKeys = keyof User; // 'id' | 'name' | 'email' type IdType = User['id']; // number // 把对象所有属性值改成布尔值 type Booleanify<T> = { [K in keyof T]: boolean; }; type UserFlags = Booleanify<User>; // { id: boolean; name: boolean; email: boolean }5.3 泛型中的隐含问题:联合类型会分布式执行
条件类型遇到裸类型参数(没有包装在元组或数组里)时会自动拆解联合类型逐个判断,这个叫分布式条件类型。很多“为什么结果和我想的注释不一样”的问题都源于此:
type IsString<T> = T extends string ? true : false; type A = IsString<string | number>; // 结果是 boolean,因为 TS 先分拆成 IsString<string> | IsString<number> // 也就是 true | false,等价于 boolean如果不希望分布式行为,把泛型参数用元组包一层:
type IsStringDistributedAvoided<T> = [T] extends [string] ? true : false; type B = IsStringDistributedAvoided<string | number>; // false6. 高级类型工具:映射、条件、推断,一套组合拳
从TS 4.1开始,官方内置的工具类型基本覆盖了大部分场景,但在源码阅读和库开发中,自己写高级类型是躲不开的。我把最核心的三个“元能力”拆开讲清楚。
6.1 映射类型与键的重映射
映射类型就是遍历联合类型生成新对象类型。键重映射靠as关键字,可以过滤和改名:
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]; }; type User = { id: number; name: string }; type GettersUser = Getters<User>; // { getId: () => number; getName: () => string; }注意这里Capitalize<string & K>的写法,因为K在映射过程中可能是string | number | symbol,而Capitalize只接受string,需要先用string & K做类型收窄。
6.2 条件类型与 infer:类型版的模式匹配
infer是在条件类型里“声明一个待推断的类型变量”,配合extends做模式匹配。数组、函数、Promise的解包都靠它:
type ElementType<T> = T extends (infer U)[] ? U : T; type A = ElementType<string[]>; // string type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never; type B = GetReturnType<() => number>; // number type UnwrapPromise<T> = T extends Promise<infer U> ? U : T; type C = UnwrapPromise<Promise<boolean>>; // boolean这三行代码已经能解决前端日常80%的“取内部类型”需求。再往上走一层,infer配合递归可以解析出更复杂的结构:
type DeepUnwrap<T> = T extends Promise<infer U> ? DeepUnwrap<U> : T; type D = DeepUnwrap<Promise<Promise<Promise<string>>>>; // string6.3 模板字面量类型:字符串层面的类型计算
TS 4.1引入了模板字面量类型,可以在类型层面操作字符串:
type EventName = `on${Capitalize<`${string}`>}`; type E = 'onClick'; // 满足 EventName实际项目里我用它做过事件映射表和API路径推导。再配合条件类型,可以写一个路径参数解析器:
type ParsePath<T extends string> = T extends `${infer Start}/${infer Rest}` ? Start extends `:${infer Param}` ? { [K in Param]: string } & ParsePath<Rest> : ParsePath<Rest> : T extends `:${infer Param}` ? { [K in Param]: string } : {}; type PathParams = ParsePath<'/users/:id/posts/:postId'>; // { id: string; postId: string }6.4 完整工具类型速查表
内置工具类型最好反推一遍源码,比背文档牢记得多。我把高频的几个做了一个表,建议直接抄走:
| 工具类型 | 作用 | 典型场景 |
|---|---|---|
Partial<T> | 所有属性可选 | 更新操作的入参 |
Required<T> | 所有属性必填 | 表单全量提交 |
Readonly<T> | 所有属性只读 | 配置冻结 |
Pick<T, K> | 挑选一组属性 | 按需透传字段 |
Omit<T, K> | 剔除一组属性 | 去掉敏感字段 |
Record<K, V> | 构造键值对象 | 字典映射 |
Exclude<T, U> | 从联合类型剔除 | 过滤事件类型 |
Extract<T, U> | 从联合类型提取 | 筛选特定类型 |
NonNullable<T> | 去掉null和undefined | 清理可选值 |
ReturnType<T> | 取函数返回类型 | 解包业务函数 |
Parameters<T> | 取函数参数元组 | 透传参数 |
InstanceType<T> | 取class实例类型 | 实例化工厂 |
7. 类型编程的实战套路:从“看得懂”到“写得出来”
这一节是全文最有操作价值的部分,把我从真实项目里提炼出来的几个高频模式分享出来。这些模式几乎每天都能用到,直接复制改改就能适配你的业务。
7.1 类型守卫:让收窄逻辑可复用
typeof、instanceof、in这些操作符都能做类型收窄,但把它们封装成自定义类型守卫函数,可以让收窄逻辑复用,配合unknown类数据尤其好用:
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value); } function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === 'string'); }注意返回值类型是value is Xxx这种断言语法,它和布尔返回值不同,TS会把这个函数视为可信的收窄工具。
7.2 从数据常量推导联合类型
TS推荐用as const+keyof typeof从常量对象反向推导联合类型,我是这套写法的重度用户:
const ROUTES = { home: '/', about: '/about', contact: '/contact', } as const; type RoutePath = typeof ROUTES[keyof typeof ROUTES]; // '/home' | '/about' | '/contact' type RouteKey = keyof typeof ROUTES; // 'home' | 'about' | 'contact'这比手动写一遍联合类型要好维护得多:新增路由时自动生效,不会出现两边不同步的问题。
7.3 类型体操的经典例题:DeepPartial与DeepReadonly
面试和源码里经常见到递归映射类型的实现,核心是判断属性值是否是对象,是就递归处理:
type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]; }; type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]; };需要特别注意的是,T[K] extends object ? ... : ...这个判断对函数类型也成立,但函数不应该被递归展开。所以更严谨的写法要加T[K] extends (...args: any[]) => any ? T[K] : ...这个分支,这个坑我补了好几次才想起来。
7.4 条件类型+infer组合:从复杂嵌套API中提取类型
在对接第三方SDK时经常遇到深嵌套的响应结构,用类型工具一次性把内部类型提取出来:
interface ApiResponse<T> { code: number; data: T; message: string; } type UnwrapApi<T> = T extends ApiResponse<infer D> ? D : never; // 假设某接口返回的是用户列表 type UserListResp = ApiResponse<{ items: User[]; total: number }>; type UserListData = UnwrapApi<UserListResp>; // { items: User[]; total: number }7.5 用泛型约束防止过度耦合
泛型的约束不仅能做类型检查,还能减少调用方不必要的传参负担。我常用的一种模式是“默认推断+可覆盖”:
interface PaginationOptions<T> { items: T[]; current: number; pageSize: number; } function paginate<T>(options: PaginationOptions<T>): T[] { const { items, current, pageSize } = options; return items.slice((current - 1) * pageSize, current * pageSize); }8. 声明文件、模块解析与编码规范:让类型体系融入工程
类型体系的终点不只是写几个类型工具,而是把它融入工程规范,保证团队协作时类型不被随意绕过。
8.1 declare的三种高频用法
declare module+ 通配符:给非JS资源声明类型,比如CSS模块和图片资源。declare global:往全局作用域补充类型,用于扩展Window、String接口。declare function/declare const:描述一段已存在的JS实现类型。
一个覆盖导入CSS模块的声明文件示例:
// env.d.ts declare module '*.module.css' { const classes: { readonly [key: string]: string }; export default classes; }8.2 三斜线指令与types字段
/// <reference types="node" />在底部类型声明文件里会让依赖关系更明确。同时tsconfig.json里的types字段建议显式列出需要加载的包,避免所有@types/*包全部自动加载拖慢编译。我一般这样配置:
{ "compilerOptions": { "types": ["node", "jest", "vite/client"] } }8.3 tsconfig的几个关键严格选项
strict模式全家桶里我建议以下选项必须开:
{ "compilerOptions": { "strict": true, "noImplicitAny": true, "strictNullChecks": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true } }这里重点说noUncheckedIndexedAccess,它会对索引签名、数组下标的读取结果自动加undefined。很多人一开始不习惯,因为arr[0]变成了string | undefined,强制你处理越界问题,但上线后减少的空引用错误是实打实的。
8.4 团队类型规范速查清单
我整理了一份项目里实际执行过的类型编写规范,分享出来供参考:
- 禁止滥用
any,外部输入统一走unknown+ 类型守卫收窄。 - 公开API的入参和返回值必须显式标注类型,不依赖推断。
- 对象结构优先用
interface,联合类型、映射类型、工具类型组合用type。 - 枚举类型尽量用
as const对象替代,运行时行为更可控。 - 复杂嵌套的响应数据,提供对应的提取工具类型,不让外部直接面对深层泛型。
catch的异常对象默认是unknown,先判错再处理。- 注释只写“为什么”,不写“是什么”,因为类型本身就是文档。
9. 常见类型错误与排查实录
最后这部分全是我和团队在实际开发中踩过的坑,每个都有真实的报错现场,按频率从高到低罗列。
9.1 类型报错“Type 'undefined' is not assignable to type 'string'”
几乎所有刚开严格模式的人都会遇到。原因在于可选属性、数组越界、Map.get()等API,只要严格Null检查开启,undefined就显式存在。解决办法不是关闭strictNullChecks,而是把对这个值的处理路径显式写出来。我曾经在一个老项目里把strictNullChecks关掉,结果运行时空引用bug集中爆发,后来老老实实全开,再通过??兜底逐层修掉。
9.2 回调函数的参数类型被误解:逆变与协变
TS对函数参数类型在赋值时采用的是“双向逆变检查”(bivariant),这在一部分场景会放过错误代码。一个典型例子是事件回调的参数类型宽窄不匹配可能不报错,但运行时又确实会出问题。遇到这类疑难杂症,建议给回调显式标注参数类型,不要依赖上下文推断。
9.3 “Object is possibly 'null'” 频繁出现
document.getElementById返回的类型就是HTMLElement | null,访问属性前先判空:
const el = document.getElementById('app'); if (el) { el.innerHTML = 'hello'; }另一种更优雅的方式是使用!非空断言,但只在确实确定不可能为null时使用,我一般给自己写的函数保证非空返回时才用。
9.4 泛型函数“could be instantiated with a different subtype”
这个报错通常出现在泛型参数没有正确约束时。比如:
function getLength<T>(obj: T): number { return obj.length; // 报错 }报错原因在于T可以是被传入的任何类型,TS无法假设它有length属性。解决办法是给T加上extends { length: number }约束。
9.5 声明合并意外污染全局类型
interface同名自动合并很方便,但它也会导致“以为在声明局部类型,实际却改了全局”的问题。我在一个模块的.d.ts里声明interface Config,结果项目里所有Config都被污染成了一份类型。后来团队规定:全局声明文件里只允许声明全局变量和扩展第三方接口,业务类型一律通过export导出再引入。
10. 最后一轮小测验:看看这份类型体系你掌握了多少
把一篇长文的干货变成自己的东西,最好的方式就是做题。我留了五道小练习,先自己思考再看答案思路。
- 实现一个
ToReadonly工具类型,把对象所有属性变为readonly,但嵌套对象不变。 - 实现一个
Merge<A, B>类型,让B的属性覆盖A的同名属性,其余保留。 - 写一个类型守卫,判断一个值是
Record<string, string>。 - 定义一个泛型函数,接收一个Promise并返回其解包后的内部值类型。
- 实现
PartialByKeys<T, K>,只让指定的键变为可选。
简要参考思路:第一题用{ readonly [P in keyof T]: T[P] }即可;第二题可用Omit<A, keyof B> & B;第三题参考value is Record<string, string>的守卫声明;第四题用T extends Promise<infer U> ? U : T;第五题用Omit<T, K> & Partial<Pick<T, K>>。
我在带团队的时候最喜欢用这五道题检验候选人的类型功底,能顺畅写出前四道的基本上可以放心让写业务类型,第五道能秒出的属于类型编程已经形成直觉了。
这些类型能力看起来多,但核心思想浓缩起来就三句话:一切类型皆集合,infer就是类型版模式匹配,映射加条件就是类型编程的组合子。把这三句话刻在脑子里,再遇到任何复杂类型问题,拆开看总能用已知的工具组合出来。