每个 TypeScript 项目里都有一类函数最让人头疼:接口地址不同、返回结构不同,但函数内部的逻辑几乎一模一样,每次新增业务都得复制一份,然后偷偷手动改类型。当你真正掌握泛型之后,这类问题会从“复制粘贴三遍再祈祷别改漏”变成一种“只要写一次,调用方决定返回类型”的稳定写法。
泛型是 TypeScript 里非常关键的一个能力,它做的事情可以简单概括为:让函数、接口、类在使用时不预先指定具体类型,而是把类型留成一个“参数”,等真正调用时再确定。它能替代大量any、能消除重复定义、还能把组件和工具的复用性提升一大截。这篇内容适合所有已经能写基础 TypeScript、但一碰到<>尖括号就发怵的开发者,我会从为什么需要它、基础语法、真实场景、高级推导、日常报错五个角度完整过一遍。读完你至少能独立看懂大部分业务项目里的泛型代码,也能在自己的封装中大胆用起来。
1. 为什么需要泛型:从类型写死到类型参数化
1.1 类型写死的直接后果:一改就要改三处
我最早用 TS 写业务时,很容易写出下面这种代码:
interface User { id: string; name: string; } interface Order { id: string; amount: number; } async function fetchUser(id: string): Promise<User> { return http.get(`/users/${id}`); } async function fetchOrder(id: string): Promise<Order> { return http.get(`/orders/${id}`); }短时间看没什么问题,但项目一大就会发现,fetchUser里那段请求逻辑可能还带着同样的鉴权、缓存、失败重试、loading 埋点。每加一个资源类型,就要复制一份几乎相同的代码,然后改掉函数名、请求路径和返回类型。如果哪一天后端把id字段改成recordId,你会在这三四处拷贝代码中反复查找替换,漏一处的概率相当高。
这其实就是标题里“TypeScript 和 JavaScript 的区别”很典型的一个体现:JS 运行时根本不管返回什么,写一个fetch函数就能通吃所有资源;可 TS 如果想要类型安全,就不能对返回结果睁一只眼闭一只眼。泛型正是为这种“逻辑相同但类型不同”的场景而生的——把类型本身变成一个参数,函数体只写一遍,类型安全照样保留。
1.2 any 不是解放,是让类型系统退出保护层
也有人会想:既然如此,我直接写any不就行了吗?
async function fetchData(url: string): any { return http.get(url); } const user = await fetchData('/users/1'); user.phoneNumber.toUpperCase();上面这段代码在编译阶段几乎不会给你任何警告。user上有没有phoneNumber字段、phoneNumber是不是字符串、能不能调用toUpperCase,全要等程序跑到运行时才知道。TS 的价值恰恰在于把你的错误尽量拦在编码阶段,一旦用了any,这个保护层就从这里断掉了,而且断得毫无痕迹。
不只是某个字段的漏判,any还会污染下游:一个函数返回any,所有拿到这个返回值的地方都会默默失去类型推导。这也是很多人觉得“项目里好像没写几个 any,但类型总不起作用”的原因之一。泛型的思路和any完全相反,它不想放弃类型,而是想把“该是什么类型”的决定权延后,同时仍然保留类型间的关系和约束。
1.3 泛型的本质:把类型当作参数传递
一句话理解泛型:它不是让 TS 变得“更灵活”,而是让类型之间还能保持“联动关系”。
我们看一个最普通的泛型函数:
function firstElement<T>(arr: T[]): T | undefined { return arr[0]; }<T>这里的T是一个“类型参数”,函数体内部不知道该参数具体是什么,但是它知道:返回值的类型一定等于数组元素类型,这层关系没有丢失。调用时,你可以由编译器自动推导,也可以显式指定:
const num = firstElement([1, 2, 3]); // T 推导为 number const str = firstElement<string>(['a']); // 显式指定 T 为 string如果不用泛型,把这个函数写成固定返回any,那类型关系就断了;如果固定返回number,那字符串数组又用不了。泛型做的是第二层抽象:把“值参数化”的方式同样用到“类型”上,在编译期建立一个可以由调用方填充的占位符,然后在填充的瞬间完成全面的类型校验。这是 JS 根本没有的能力,也正是泛型值得单独花一篇去讲的原因。
2. 泛型基础语法:函数、约束、默认值一次说清
2.1 泛型函数:两种调用方式与类型推断边界
泛型函数最常见的长相是下面这种:
function identity<T>(value: T): T { return value; }它的意思是:传入一个T类型的值,返回同一个T类型的值。调用时可以依赖推断,也可以显式指定。对绝大多数常规调用,我建议优先让 TS 自己推断,代码更短更干净;但当推断结果不是你想要的,或者函数在对外暴露的 API 中难以从参数看出T时,就要显式写出类型实参。
还有一种很容易被忽视的写法:箭头函数泛型。
const identity = <T>(value: T): T => value;在.tsx文件里,你可能会碰到下面这个有点怪异的写法:
const identity = <T,>(value: T): T => value;多出来的那个逗号是用来告诉 JSX 解析器“这是泛型尖括号,不是 React 标签”,老项目中经常见到。如果只用.ts文件,不带逗号没有问题,所以看到<T,>时不要觉得陌生,它本质上就是<T>。
2.2 extends 关键字在这里不是继承,而是约束
泛型只写一个<T>,意味着调用时可以传任意类型。很多场景其实不需要也不应该允许任意类型,比如一个保存用户信息的函数,至少要求传入的对象含有id。这时就该给泛型加约束:
interface Identifiable { id: string; } function save<T extends Identifiable>(entity: T): T { console.log(entity.id); return entity; } save({ id: 'u_1', name: '张三' }); // 合法,对象里有 id save({ name: '张三' }); // 报错:没有 id这里extends经常让新手误会成“类的继承”。在泛型约束中,它表达的更接近“T 必须满足某个结构,至少要具有指定成员”。你用“鸭子类型”的思路去理解它就行:只要运行时对象里有id: string,它在类型层面就符合Identifiable约束,不要求这个对象真的从一个基类派生出来。
这个能力在业务里的价值非常大。比如你要封装一个通用的请求缓存,那就可以约束:
function getCached<T extends { id: string }>(list: T[], id: string): T | undefined { return list.find((item) => item.id === id); }正是通过extends,你既获得了复用的自由,又保住了操作对象成员时的安全。
2.3 默认泛型参数:处理多参数的技巧
函数可以有默认值,泛型类型参数也同样可以指定默认类型。默认泛型主要在“调用方不传参时也能有兜底”的场景特别有用。
function createArray<T = string>(length: number, value: T): T[] { return Array.from({ length }, () => value); } const a = createArray(3, 1); // number[],推导优先 const b = createArray<string>(3, 'x'); // string[]默认值最常出现在封装第三方库或基础组件时。比如一个setState风格的功能,前期大多数组件存的状态是普通对象,但个别组件确实要存别的结构:
interface StoreOptions<TState = Record<string, unknown>> { initialState: TState; }当你定义,调用方如果不指定泛型,它可以按默认类型工作;指定了,就完全以指定类型为准。多泛型参数的默认值还有一个规则要注意:一旦某个类型参数给了默认值,它右边的类型参数最好也都有默认值,否则使用时不完整指定就会报错。
2.4 泛型接口与泛型类:把模板落到更多地方
类型参数不只属于函数。接口和类同样可以定义自己的泛型。最常见的响应结构封装会写成这样:
interface ApiResponse<T> { code: number; message: string; data: T; } interface UserProfile { userId: string; nickname: string; } function getUserProfile(): Promise<ApiResponse<UserProfile>> { return http.get('/profile'); }这个ApiResponse<T>就像一个通用了所有响应数据的模板,T换成什么,data字段就是什么。类也可以有自己的泛型参数,典型实现就是一个极简仓库:
interface Entity { id: string; } class MemoryRepository<T extends Entity> { private readonly items = new Map<string, T>(); save(item: T): void { this.items.set(item.id, item); } findById(id: string): T | undefined { return this.items.get(id); } }类泛型和函数泛型共同使用一套 extends 约束和默认类型规则,理解了一个,另一个基本就是同样的思维。这里也顺带回应一点:无论怎样写,类型在编译完成之后都不存在,不会额外占用运行时内存,这也是它和“用类做编程抽象”的本质区别。
3. 实战进阶:用泛型解决真实项目里的重复与类型安全问题
3.1 封装一个真正通用的请求基础层
请求封装是泛型最典型的落地场景。假设后端响应统一长这样:
{ "code": 0, "message": "ok", "data": { ... } }如果没有泛型,你会为每个业务接口写一个类型守卫,或者干脆返回any,然后业务层到处断言。有了泛型之后,可以用一个函数包住所有请求动作:
interface HttpResponse<T> { code: number; message: string; data: T; } async function httpGet<T>(url: string): Promise<T> { const response = await fetch(url); const body: HttpResponse<T> = await response.json(); if (body.code !== 0) { throw new Error(body.message || 'Request failed'); } return body.data; } // 调用方这边就很舒服 interface CommentItem { id: number; content: string; } const comments = await httpGet<CommentItem[]>('/comments');httpGet<T>有两个耐人寻味的地方。第一,T描述的是真正业务里需要的数据类型,而不是外层的code/message/data包装;第二,不管有多少接口,这一层逻辑只写一次。你想在返回数据前统一做 cancel token、统一埋点、统一错误上报,都只用动这一处。
如果你用的是 axios,也可以做类似封装:
import axios, { AxiosResponse } from 'axios'; async function request<T>(config: { url: string; params?: Record<string, unknown> }): Promise<T> { const response: AxiosResponse<HttpResponse<T>> = await axios.request(config); return response.data.data; }这里你只需要关心“T 和最终业务数据的对应关系”,网络层细节全部封装在函数内部,这比面向接口复制代码要优雅得多。
3.2 从响应结果中安全提取嵌套字段
真实后端返回的数据很少永远扁平的,比如分页结构:
interface PageResponse<T> { list: T[]; page: number; pageSize: number; total: number; } async function fetchPage<T>(page: number): Promise<PageResponse<T>> { return request<PageResponse<T>>({ url: '/items', params: { page } }); }于是fetchPage<User>()返回的list就是User[],fetchPage<Order>()就是Order[]。这种结构很容易扩展,就算未来再加一层“statistics”字段,你的泛型参数也只要在PageResponse<T, U>里再加一个即可。
这类设计的核心收益,不是把类型“从 A 换成 B”,而是当后端改动数据结构时,类型检查会立刻告诉你的业务代码哪里受影响,而不是在线上跑挂了才被用户发现。类型安全最重要的红利就在这里。
3.3 仓储类、状态容器与泛型实例
除了请求层,项目里常见的状态容器也适合泛型。
class StateStore<TState> { private state: TState; constructor(initialState: TState) { this.state = initialState; } getState(): TState { return this.state; } patch(partial: Partial<TState>): void { this.state = { ...this.state, ...partial }; } } interface CartState { items: string[]; couponCode?: string; visible: boolean; } const cartStore = new StateStore<CartState>({ items: [], visible: false }); cartStore.patch({ couponCode: 'SAVE10' }); // 合法 cartStore.patch({ items: 123 }); // 报错:items 必须是 string[]这里还顺带用到了内置工具类型Partial<TState>:它把TState里所有属性都变成可选项。你在编辑大型对象时可以只传部分字段,而不需要把整个对象都重建一遍。为什么要强调这个例子?因为它说明泛型之间可以自由组合,函数、接口、类、内置工具类型彼此嵌套,最终构成一套自己的业务约束体系。
如果把 store 做成一个函数而非类,泛型同样可以胜任:
function createStore<TState>(initial: TState) { let state = initial; return { get: (): TState => state, set: (next: TState) => { state = next; } }; } const userStore = createStore<User | null>(null);此时createStore的TState完全由用户的传入类型决定,函数返回的对象中get的返回值类型也自动变得正确,不需要额外写类型注解。
3.4 为什么说泛型能减少“面向场景复制代码”
我把前面这些串起来说一下。业务项目里大量重复并不体现在“每行代码都相同”,而是“结构相同、类型不同”。分页请求、本地缓存、状态合并、表单校验、消息订阅,它们全都是同一种套路。没有泛型时,你会为每个业务模型复制一套完整方案并手动维护;有泛型后,你只需要把“变化的那部分”作为类型参数暴露出去,剩下的公共逻辑写在模板中。
在你定义模板时,最上层的出发点不应该是“它以后可能用到什么类型”,而应是“我目前已经知道哪些类型关系是稳定的”。比如你确定后端响应外层一定有code/message/data,那就把data设置为类型参数;你确定仓库实体的主键一定是id,那就用T extends { id: string }保证这条路不会走歪。明确稳定结构,泛型才能用起来自然。
4. 深入理解工具类型:从抄用法到看懂内部实现
4.1 各种工具类型背后的同一套路
TS 内置了不少泛型工具类型,比如Partial、Required、Pick、Record等等。很多人把它们当“魔法方法”在背,但如果你能自己实现一次,理解立刻就不一样了。工具类型的本质都是一个“接收类型参数的泛型类型”,内部用映射类型把 A 结构变成 B 结构。
4.2 手写一遍 Partial 和 Pick:豁然开朗
拿Partial来说,官方定义其实就是一行核心逻辑:
type MyPartial<T> = { [P in keyof T]?: T[P]; };keyof T的意思是取出 T 的所有属性名组成联合类型;P in keyof T表示遍历这个联合类型中的每一个属性名;: T[P]表示新对象的属性值仍然保持原来的类型。末尾的?让所有属性变成可选项。这就是映射类型,它和for...in很像,只是遍历的是“类型层面”。
Pick<T, K>的实现也很简单:
type MyPick<T, K extends keyof T> = { [P in K]: T[P]; };K extends keyof T表示你要选的属性名必须属于 T 的键集合。举个例子:
interface Article { title: string; content: string; authorId: string; createdAt: Date; } type ArticlePreview = Pick<Article, 'title' | 'authorId'>;整个过程相当直观:告诉编译器“我只保留 title 和 authorId 两个属性”,得到的ArticlePreview会精确包含这两个字段及对应类型。手写一遍之后,再查资料时会有非常明确的思维方向。
4.3 条件类型和 infer:从复杂类型中抽取片段
infer是泛型进阶中比较绕但极其常用的一个关键字。它允许你在条件类型里声明一个待推断的类型变量,然后TS在匹配过程中自动填充它。典型的例子是取 Promise 的返回值类型:
type Awaited<T> = T extends Promise<infer R> ? R : T; type Result = Awaited<Promise<string>>; // string type Result2 = Awaited<number>; // numberT extends Promise<infer R>的意思是:如果 T 可以匹配上某种 Promise,那么其中的 R 到底是多少,由编译器去推断。比如T是Promise<Customer>,编译器就自动得出R = Customer。紧接着还以这个 R 替换三元分支中true一侧的表达式。
数组元素类型也可以用同样的思路抽取:
type ElementOf<T> = T extends Array<infer E> ? E : never; type Item = ElementOf<string[]>; // string如果你在项目里经常用 Redux、React Query 或者各种第三方库,一定会遇到需要从某个复杂函数的返回值里“抠”出类型的场景。手动把这个函数类型写一遍非常繁琐,用ReturnType加Awaited包裹一下就解决了:
type Result = Awaited<ReturnType<typeof fetchUserProfile>>;这就是深入理解“Promise + infer + ReturnType”嵌套的真实收益。
4.4 条件类型分配特性:为什么会得到联合类型
条件类型有一个很容易踩坑的分配特性,中文社区常叫它“分布式条件类型”。当 T 是一个联合类型时,条件类型会把联合类型的每个成员单独过一遍条件判断,再把结果合并成联合类型。
type ToArray<T> = T extends string ? string[] : number[]; type A = ToArray<string | number>; // string[] | number[]如果你不希望它逐个成员生效,而是想整个联合类型作为一个整体去判断,可以给条件类型中检查的类型包一层[]:
type ToArrayNonDistributive<T> = [T] extends [string] ? string[] : number[]; type B = ToArrayNonDistributive<string | number>; // number[]这个“要不要分布式”的差异,会导致同一个类型工具在不同联合类型下的结果完全不同。在这上面栽过跟头的开发者不少,所以我建议你在自定义复杂的条件类型时,多测试几个联合类型输入,确认结果是否符合直觉;同时给这些类型写一些注释,不然三个月后你自己回来看也会懵。
5. 泛型实践规范、高频报错与排查技巧
5.1 类型推断失败时,先看看是不是缺了约束
泛型最常见的第一类报错,是“TS 无法确定 T 是什么”。看这段代码:
function getFirst<T>(list: T[]): T { return list[0]; }如果你关闭了strictNullChecks,它可能不报错;但更严格的配置下,由于list[0]在运行时可能是undefined,TS 会提示返回值可能不是 T。很多时候不是泛型“推断不出来”,而是你操作对象的范围太宽。解决方案无非两种:允许返回T | undefined,或者在调用处确保一定有值:
function getFirst<T>(list: T[]): T | undefined { return list[0]; } function getFirstStrict<T>(list: T[]): T { if (list.length === 0) { throw new Error('list is empty'); } return list[0]; }这个问题我在项目里看得非常多。新手觉得是“泛型写复杂了导致报错”,实际上成因是数组下标访问本身存在边界风险,TS 在严格模式下宁可报错也不放行。想减少这类报错,核心不是删掉泛型,而是先把函数要考虑的空值情况考虑完整。
5.2 显式传泛型和推导结果不一致
第二种高频问题,是显式指定了类型参数之后,传入实参无法满足约束。例如:
function logAndReturn<T extends string>(value: T): T { console.log(value); return value; } logAndReturn<string>('hello');这个没问题。但如果你写:
function guessValue<T>(value: number | string): T { return value as unknown as T; }这种“硬把泛型当任意类型转换”的做法,很容易在调用方完全指定错误的类型时逃过编译,直到运行时才报错。它本质上是用as unknown as T暴力跳过了类型系统,和使用any的后果是一样的。正确做法通常是:让类型参数参与推导过程,或者通过约束缩小范围:
function guessValue<T extends number | string>(value: T): T { return value; }如果必须要做类型转换,请把它收敛在一个较小的内部函数里,并添加注释说明为什么这里需要as。直接暴露一个会强制抹平类型的泛型函数,会让整个链路失去保护,是泛型使用上最常见的“假安全”写法。
5.3 泛型与默认值、重载选择有关联时容易出幻觉问题
有人喜欢这样写:
function createItem<T>(defaultValue?: T): T { return defaultValue ?? ({} as T); }这段代码在strictNullChecks下并不会报错,但调用时很容易产生误导:
const item = createItem(); // item 的类型被推断为 unknown,而不是理想中的 {}由于没有传参,TS 无法推导 T,就只能把T当作unknown。(如果最终赋给某个确定类型的变量,可能还会报无法转换。)这种“使用默认值”的场景其实不太适合用泛型硬撑,尤其是当返回值可能是空对象时。更稳妥的写法是泛型约束加默认对象或者默认函数:
function createItem<T extends object = Record<string, never>>(defaultValue?: T): T { return defaultValue ?? ({} as T); }注意这里的默认类型处理,能改善很多隐式 unknown 带来的问题。关键教训是:泛型本身不提供运行时默认值,它只负责类型。不要期待<T = Something>会在运行时真的创建一个 Something 实体。
5.4 一个容易忽视的编译配置优化:响应 baseUrl 替换提示
如果你从较早版本的 TS 一路升级上来,控制台可能出现过类似 “option 'baseurl' is deprecated and will stop functioning in TypeScript 7.0” 的提示。这跟泛型关系不大,但它属于项目运行 TS 时常见的警告一环:新版本不推荐在 tsconfig 中配置baseUrl,而推荐模块路径用相对路径或用paths配合更明确的方式。
如果你在升级 TS 后收到这类提示,不要慌,把它看成一次工程配置的整理机会:把 tsconfig 里的"baseUrl": "./src"删掉,调整 imports 里的路径写法,保持路径清晰。这类配置上的整洁,会让泛型类型在跨文件使用时更容易被编译器正确解析,也免得开发时总是出现奇怪的模块解析报错。
5.5 团队协作中的泛型使用约定
最后分享一些我在实际项目里推进泛型时慢慢形成的约定,很主观,但都是踩坑换来的经验。
一,优先推断,显式用于公共 API。内部实现能用推断就不必在每个函数调用处写一堆尖括号,否则代码噪声非常大。对外导出的函数、组件、类型定义,则要写明泛型约束和语义化名字,让使用者从签名就能看出“这个函数到底要接收什么结构”。
二,命名长度随作用域走。泛型参数的名字不只是为了给编译器看,更是给人看。TState、TItem、TResp这类带语义前缀的命名,在复杂类型中远比T、U可读。简单的数组转换工具用T没问题,一但泛型参数超过两个,强烈建议取名更明确。
三,控制泛型数量。一个函数的泛型参数超过三四个,调用的可读性就会严重下降。这时你通常应该考虑新增一个描述型接口,把多个类型参数收拢进一个Options<T, K>结构,再由内部索引类型去拆解,而不是让调用方每次写一堆<string, boolean, number, User>。
四,严格引用组件/工具的泛型。说白了就是不要为了“通用”而无脑上泛型。如果一个函数只有一个调用场景,固定在函数内部声明需要的类型比泛型更简单;如果一个逻辑从设计上就确定未来至少有三四个复用场景,再引入泛型也不迟。泛型和优秀工程里的很多东西一样,“在正确的时候做正确的事”比“全部抽象”重要得多。
6. 常见问题速查与最后一点建议
| 现象 | 可能原因 | 处理思路 |
|---|---|---|
| 泛型函数返回体报 “T is not assignable” | 你在函数里对传入值做了太多越界操作 | 检查是否缺少约束,或把“输入到输出必须保持同一类型”拆开成两个类型参数 |
| 调用函数没传类型参数,结果类型变成 unknown | 无法通过参数推导 T | 显式传类型参数,或给泛型设置默认类型 |
<T,>写法看不懂 | 项目在.tsx环境 | 这是与 JSX 解析兼容的正规写法,无需修改 |
| 用 Pick/Record 时报 “Type 'K' does not satisfy the constraint” | K 的范围超出了 T 的键集合 | 检查 K 是否必须extends keyof T |
| 嵌套泛型后代码极难阅读 | 泛型层级过深,或没有给类型参数起名字 | 抽一个别名,语义化类型,必要时拆成多个助手类型 |
| 控制台提示 baseUrl deprecated | 当前 TS 版本较新,模块解析方案迁移中 | 移出 baseUrl,规范路径即可,不影响泛型本身 |
这些坑不是只靠看文档就能避开的,很多都要在具体项目里被编译器教育过几回才会形成直觉。我的个人建议是:不要一上来就尝试写特别复杂的条件类型,先把函数泛型、接口泛型、类泛型和约束这几项吃透,能在真实项目里封装出一个请求层或 store 层,再研究 infer 和分布式条件类型。
泛型真正的价值在于,它让你能把公共逻辑和差异类型解耦,让“结构不变、类型不同”的代码只维护一份,同时每个调用点都能获得精确的类型提示。这种收益在项目规模变大后会越来越明显。从下一个重复代码片段开始动手抽一个泛型版本吧,你会在第一次调用时体会到那种“写一次,处处都安全”的舒畅感。