写泛型文章的人很多,但大多数不是停留在语法讲解,就是把官方文档抄一遍。这篇不一样,我不打算从“什么是泛型”这种教科书式的问题讲起,而是直接把它放在一个“没有泛型会怎样”的冲突场景里,用我这些年写 TypeScript 踩过的坑、面试中被问到的角度,以及实际项目中怎么设计泛型 API 的思路,把泛型这个看起来绕、用起来香的特性彻底聊透。如果你正准备面试、或者正在封装自己的组件库和工具函数,这篇值得你花 15 分钟读完。
1. 泛型到底在解决什么问题
1.1 没有泛型的世界有多痛苦
想象一下,你写一个取数组最后一个元素的函数,没有泛型的时候只能这样:
function getLast(arr: any[]): any { return arr[arr.length - 1]; } const num = getLast([1, 2, 3]); // num 类型是 any num.toFixed(); // 代码能跑,但编辑器根本不知道 num 有没有这个方法用any虽然能跑,但等于把 TypeScript 的类型保护全部丢掉了。你拿到的返回值没有任何约束,写错了也只有运行时才能发现,这和直接用 JavaScript 有什么区别?
还有一种常见做法是写多个重载:
function getLast(arr: number[]): number; function getLast(arr: string[]): string; function getLast(arr: boolean[]): boolean; // 每来一种类型就要写一遍,根本写不完这就是泛型出现之前 TypeScript 写通用函数的尴尬局面——要么牺牲类型安全,要么维护一堆重复代码。泛型的思路很简单:类型也像参数一样传进去,调用的时候才知道具体是什么类型,但类型关系在编译期就已经被牢牢锁死了。
1.2 泛型的本质:类型层面的函数
如果你写过 JavaScript 函数,理解泛型就很容易。普通函数是“值到值”的映射,传一个值进去,返回一个值;泛型是“类型到类型”的映射,传一个类型进去,返回一个类型。
function getLast<T>(arr: T[]): T { return arr[arr.length - 1]; } const num = getLast([1, 2, 3]); // T 推断为 number,num: number const str = getLast(['a', 'b', 'c']); // T 推断为 string,str: string这里T就像函数里的形参,调用时 TypeScript 会根据传入的数组元素类型自动推断出T的具体值。调用getLast([1,2,3])时,T被实例化为number,返回值的类型就是number,写错了num.toFixed()这种调用完全没问题,而如果你写num.split(''),编辑器直接飘红。
这里有个理解误区我要特别强调:泛型不是“任何类型都可以传”的放任不管,而是当类型关系需要保持连通时的一种约束机制。你说“数组是什么类型,取出来的元素就是什么类型”,这句话本身就是一个类型层面的逻辑,泛型就是把这种逻辑表达出来的工具。
2. 泛型基础语法与核心概念
2.1 类型参数、类型推断与显式指定
先看最基础的类型参数写法:
// 基础写法 function identity<T>(value: T): T { return value; } // 多个类型参数 function swap<T, U>(pair: [T, U]): [U, T] { return [pair[1], pair[0]]; } // 箭头函数写法,别漏了后面的逗号 const identity2 = <T,>(value: T): T => value;TypeScript 会尽量根据上下文推断类型参数,但有些场景推断不出来或者推断得不对,就需要显式指定:
// 显式指定类型参数 identity<string>('hello'); swap<number, string>([1, 'a']); // 经典坑:JSON.parse 的返回类型 function parseJSON<T>(text: string): T { return JSON.parse(text); } interface User { name: string; age: number; } const user = parseJSON<User>('{"name":"tom","age":18}'); user.name; // string,类型安全这里有个非常重要的经验:不要滥用类型断言绕过泛型推断。比如上面的parseJSON,有人会写成:
const user = JSON.parse('{"name":"tom","age":18}') as User;这虽然也能让user有类型,但JSON.parse本身的返回值是any,你用as User是给any强行套了一层皮,编译期没有任何校验。而parseJSON<User>(...)这种写法,类型参数T在函数签名里就有约束,维护性更好,代码语义也更清晰。
2.2 extends 约束:泛型不是“万能药”
泛型不能无限放任,extends约束就是给类型参数划定边界的工具:
interface HasLength { length: number; } // 要求 T 必须有 length 属性 function logLength<T extends HasLength>(arg: T): T { console.log(arg.length); return arg; } logLength('hello'); // string 有 length,OK logLength([1, 2, 3]); // 数组有 length,OK logLength({ length: 5 }); // 自定义对象有 length,OK // logLength(123); // number 没有 length,报错为什么需要约束?因为T默认是所有类型的集合,你在泛型函数体内不能随便访问length、push这些属性,TypeScript 会认为T上不一定存在。用T extends HasLength就告诉编译器:传入的类型必须满足HasLength这个最低要求。
约束还有一层更高级的用法,和keyof结合。这个在业务里特别常见,尤其是表单校验:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const person = { name: 'Alice', age: 23, city: 'Beijing' }; getProperty(person, 'name'); // string getProperty(person, 'age'); // number // getProperty(person, 'email'); // 报错:'email' 不在 keyof 中注意K extends keyof T这个约束,它的含义是:K 必须是 T 的键之一。这样getProperty(person, 'email')在编译期就会被拦截,不会等到运行时返回undefined才发现问题。这是目前在业务项目中泛型用得最多、最实用的一种模式。
2.3 泛型接口、泛型类和泛型默认值
除了函数,泛型还能用在接口和类上:
// 泛型接口 interface ApiResponse<T> { code: number; message: string; data: T; } // 泛型类 class Stack<T> { private items: T[] = []; push(item: T) { this.items.push(item); } pop(): T | undefined { return this.items.pop(); } } // 泛型默认值,就像函数默认参数 interface RequestOptions<T = unknown> { url: string; params?: T; } const options: RequestOptions = { url: '/api' }; // T 默认 unknown泛型默认值这个特性在实际项目中很好用。比如你封装的请求库,很多请求没有params,你不需要每次调用都手动传类型参数,默认unknown已经够用;有针对性的请求再指定具体的参数类型即可。这和 Java、C# 的泛型默认值不太一样,TS 是在类型层面给一个兜底,不会出现运行时行为差异。
3. 进阶用法:条件类型、infer 与工具类型
3.1 条件类型:类型层面的 if-else
这是泛型最强的地方。条件类型的语法是T extends U ? X : Y,就像三元表达式一样在类型层面做判断:
type IsString<T> = T extends string ? true : false; type A = IsString<'hello'>; // true type B = IsString<number>; // false这个语法看着简单,但组合起来能玩出花。比如定义一个类型,它能把函数类型的返回值提取出来:
type ReturnTypeOf<T> = T extends (...args: any[]) => infer R ? R : never; type Fn = () => number; type Result = ReturnTypeOf<Fn>; // number这就是infer关键字,它在这里的作用是让 TypeScript 在条件类型中“反过来”推断出一个类型变量。你可以把它理解成类型层面的typeof——只不过typeof是从值拿类型,infer是从一个复杂的类型结构里提取出一部分。
3.2 infer:类型推断的陷阱与威力
继续深入一下infer。实际开发中真正用到infer的场景很多,最典型的是从 Promise 里提取返回值类型:
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T; type A = UnwrapPromise<Promise<string>>; // string type B = UnwrapPromise<Promise<Promise<number>>>; // 注意,这里不是 number为什么B不是number?因为UnwrapPromise只拆了一层。要支持递归拆解需要写成:
type UnwrapPromise<T> = T extends Promise<infer U> ? UnwrapPromise<U> : T; type B = UnwrapPromise<Promise<Promise<number>>>; // number这行代码我打赌很多人第一次看到的时候懵了,因为它把“递归”搬到了类型层面。其实理解起来并不难:先判断 T 是不是 Promise,如果是,提取出内部类型 U,再把 U 递归地传给UnwrapPromise;如果不是 Promise,直接返回 T 本身。这就是类型层面的一种“尾递归”。
还有一个很经典的面试题:如何从数组类型中提取出元素类型?
type ArrayElement<T> = T extends Array<infer U> ? U : never; type A = ArrayElement<string[]>; // string type B = ArrayElement<Array<{ id: number }>>; // { id: number }这个能力在处理从后端返回的嵌套结构时特别有用。你经常会有这样一个接口返回{ list: User[] }这种结构,想单独拿到User类型,用ArrayElement<typeof res.list>就能轻松搞定,不用再单独声明一遍User类型,避免重复、减少维护成本。
3.3 内置工具类型的实现原理
TypeScript 内置的工具类型(如Partial<T>、Pick<T, K>、Record<K, V>)很多人都用过,但能讲清楚原理的人不多。面试官特别爱问这个,我建议你把下面这几个核心工具类型亲手实现一遍:
// Partial:把所有属性变为可选 type Partial<T> = { [P in keyof T]?: T[P]; }; // Required: 把所有可选属性变为必选 type Required<T> = { [P in keyof T]-?: T[P]; }; // Pick:从 T 中选取 K 指定的属性 type Pick<T, K extends keyof T> = { [P in K]: T[P]; }; // Record:用 K 作为键、T 作为值构造对象类型 type Record<K extends keyof any, T> = { [P in K]: T; };这里最核心的运算符是keyof和映射类型(Mapped Type)[P in keyof T]。你可以把keyof T理解成“拿到对象所有键的联合类型”,然后[P in ...]遍历这个联合类型,逐一把每个键映射成一个新属性。
比如:
interface Article { id: number; title: string; published: boolean; } type PartialArticle = Partial<Article>; // 等价于 { id?: number; title?: string; published?: boolean }用一个生活化的类比:keyof相当于把所有员工的工号抽出来放在一个清单里,P in相当于遍历这份清单,把每个工号对应的人员信息重新整理一遍,?、readonly这些修饰符则是加工规则。整个映射类型就像一条流水线,输入一个对象类型,输出一个新对象类型。
顺带提一下Exclude<T, U>和Omit<T, K>的配合,这个在做接口返回类型裁剪时极其常用:
type Exclude<T, U> = T extends U ? never : T; type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;如果你要从User类型中剔除掉password字段返回给前端,直接:
type SafeUser = Omit<User, 'password'>;这个在日常业务里几乎是每天都要用到的写法。理解这些工具类型的实现原理,根本好处是当你遇到内置工具类型不够用的时候,你自己能写出定制化的类型工具,而不是被迫用any妥协。
4. 泛型在真实项目中的高价值应用模式
4.1 统一封装 API 请求与接口返回值
后端接口最常见的结构是这样的:外层包一个 code、message、data。你在前端如果每个接口都单独定义一遍返回类型,会非常啰嗦。泛型可以让你把公共部分抽出来:
interface ApiResponse<T> { code: number; message: string; data: T; } // 实际使用的接口 interface UserInfo { id: string; name: string; email: string; } // 直接这样写就能拿到完整类型 function getUserInfo(): Promise<ApiResponse<UserInfo>> { const response = await axios.get<ApiResponse<UserInfo>>('/api/user/info'); return response.data; }axios.get<ApiResponse<UserInfo>>这里的泛型参数,能让你在处理响应对象时获得完整的类型提示:response.data.data.name会提示为string,而不是any。
进一步进阶,我把请求函数封装成一层通用高层函数:
async function request<T>(config: { url: string; method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; data?: unknown; }): Promise<T> { const res = await axios({ url: config.url, method: config.method || 'GET', data: config.data, }); // 假设统一后端约定 code === 0 才说明请求成功 if (res.data.code !== 0) { throw new Error(res.data.message); } return res.data.data as T; } // 调用时非常清爽 const user = await request<UserInfo>({ url: '/api/user/info' }); user.name; // string这个封装我强烈建议团队里统一做,它把接口调用的公共逻辑收敛到了一处,同时通过泛型把每个接口的返回类型精确地“焊”在了调用点上。你会明显感觉到,写业务代码变得又快又稳。
4.2 泛型在 React 组件与 Hooks 中的实战
如果你用 React + TypeScript,泛型的价值在封装通用组件时体现得淋漓极致。一个带泛型的列表组件,可以让父组件传入数据源时自动推断每一行的数据类型:
interface ListProps<T> { data: T[]; renderItem: (item: T, index: number) => React.ReactNode; keyExtractor: (item: T) => string; } function List<T>(props: ListProps<T>) { return ( <ul> {props.data.map((item, index) => ( <li key={props.keyExtractor(item)}> {props.renderItem(item, index)} </li> ))} </ul> ); } // 使用: interface Product { id: string; title: string; price: number; } const products: Product[] = [...]; <List data={products} renderItem={(p) => <span>{p.title}</span>} keyExtractor={(p) => p.id} />注意到没有:products的类型是Product[],TypeScript 自动推断出T = Product,所以在renderItem回调里p.title这种调用,编辑器能给出完整的属性提示。如果你写错成p.name,立刻飘红。这样一个组件就能同时服务于商品列表、用户列表、订单列表,类型安全还不会打折扣。
Hooks 场景也是一样,比如封装一个通用的useRequest:
function useRequest<T>(fetcher: () => Promise<T>, deps: any[]) { const [data, setData] = useState<T | null>(null); const [loading, setLoading] = useState(false); useEffect(() => { let mounted = true; setLoading(true); fetcher().then((res) => { if (!mounted) return; setData(res); setLoading(false); }); return () => { mounted = false; }; }, deps); return { data, loading }; } // 调用 const { data, loading } = useRequest<UserInfo>(() => getUserInfo(), []); // data 类型是 UserInfo | null这里有个细节:我特意把useState<T | null>(null),而不是useState<T>(null as T)。因为初始化时数据确实是不存在的,用null更符合实际状态,也能避免类型断言带来的安全隐患。这类“类型安全优先于类型便利”的选择,是资深前端和新人拉开差距的地方。
4.3 泛型约束与 keyof 结合的表单配置器
业务中还有一个高频场景:根据某个对象的 key 配置表单。你可以用泛型让表单配置器的name字段严格限制为对象的属性名:
type FormSpec<T> = { name: keyof T; label: string; type: 'input' | 'select' | 'checkbox'; options?: string[]; }; // value 根据 name 动态变化 function buildForm<T>(spec: FormSpec<T>[]) { // 返回一个根据 spec 生成表单结构的函数 return (values: T) => { // 渲染逻辑 }; } interface UserForm { username: string; role: 'admin' | 'user'; active: boolean; } const formSpec: FormSpec<UserForm>[] = [ { name: 'username', label: '用户名', type: 'input' }, { name: 'role', label: '角色', type: 'select', options: ['admin', 'user'], }, ]; // 如果你写 name: 'email',TS 直接报错如果不用keyof T,表单配置里的name字段就只能写成string,那么你写错一个属性名编译器也发现不了,等到提交表单时values上进对应字段,要么是undefined要么直接绑错。用泛型约束以后,从配置源头上就把错误拦截了。这个模式在我做后台管理系统的动态表单时用得非常多。
5. 泛型与 .d.ts 类型声明文件
5.1 types 文件夹的声明文件怎么用
很多项目里都有src/types文件夹专门放.d.ts声明文件。泛型和声明文件结合,能给整个项目带来非常清晰的基础类型定义。
常见的做法是,定义一套业务通用类型:
// src/types/api.d.ts declare interface ApiResponse<T = unknown> { code: number; message: string; data: T; } declare interface PageResult<T> { list: T[]; total: number; page: number; pageSize: number; }这里用declare关键字声明的是全局类型,整个项目不需要import就能直接使用ApiResponse和PageResult。这种全局声明特别适合承载项目级的基础类型契约,比如所有接口返回的统一结构、分页结构的约定等。
不过要提醒一下:全局类型太泛滥会成为维护负担,所以我的习惯是——跨模块共享的基础类型放全局declare,业务局部类型放各自模块内部定义然后export。过度使用全局类型会让人很难追踪类型定义的源头,这也是新人在项目里经常踩的坑。
5.2 在 .d.ts 里编写带泛型的工具类型
如果你写的是一个工具库,并且在package.json的types字段里指向了一个.d.ts文件,那么你必须为公开函数提供精确的泛型签名,否则使用者拿到的类型就是any,等于库白写了 TypeScript 类型。
一个典型例子:
// 这是 npm 包源码 lib/index.ts export function groupBy<T, K extends keyof T>(items: T[], key: K): Map<T[K], T[]> { const map = new Map<T[K], T[]>(); for (const item of items) { const groupKey = item[key]; const list = map.get(groupKey) ?? []; list.push(item); map.set(groupKey, list); } return map; } // 对应的 lib/index.d.ts(如果是手动维护) export declare function groupBy<T, K extends keyof T>( items: T[], key: K ): Map<T[K], T[]>;这里K extends keyof T的意思是:分组依据的 key 必须是 T 上的属性名之一,同时Map的键类型是T[K]。这样使用者调用groupBy(users, 'departmentId')时,返回的Map键类型就是users中departmentId的属性类型,精确到具体联合类型。
手动写.d.ts还要注意一个点:要给泛型参数设置合理的默认值。你会在很多知名库的声明文件中看到:
declare function createInstance<T = any>(config?: T): SomeClass<T>;默认值any的意义是不想强制所有调用点都传类型参数,但又保留了使用方自行指定类型的自由。这和前面讲到的RequestOptions<T = unknown>是一个逻辑。发布一个 npm 包,泛型声明写得好不好,直接决定使用者在编辑器里的体验。
5.3 自动生成声明文件与手动声明的取舍
现在大多数项目用的是tsc或rollup/vite插件自动生成.d.ts,不需要手动维护。自动生成的声明文件通常比手写的更准确,但如果你在代码里大量使用了any或者特意做了类型收窄,生成的声明文件可能比你预期得更“宽松”。所以写库代码时就要把泛型和显式类型标注做好,留给编译器足够的信息,最后生成出来的.d.ts才够精确。
手动写.d.ts通常用在两种情况:一是给没有类型定义的第三方库补声明,二是项目里想要一套全局约定类型。两种情况都建议把泛型用上,不要为了省事把类型直接写成any或者Function,否则你补声明、写全局类型的工作就白费了。
6. 面试高频考点与常见错误排查
6.1 泛型常见面试题速答
面试中泛型被问的频率非常高,我把我遇到过的典型问题整理一下,并给出比较合理的回答思路:
Q1:extends 在泛型约束和条件类型中分别是什么意思?
回答思路:在T extends U中,extends起约束作用,表示 T 必须是 U 或其子类型。在条件类型T extends U ? X : Y中,extends用于判断 T 能否赋值给 U。这两者本质是同一个“可赋值性”检查,只是语境不同。举例说明:function foo<T extends string>()约束 T 必须是 string,而type Test<T> = T extends string ? 1 : 2是拿 T 判断是否满足 string。
Q2:infer 只能用在条件类型的哪个位置?
回答思路:infer只能出现在条件类型extends子句的“右侧”且必须在类型推断的位置。举例:
type ReturnTypeOf<T> = T extends (...args: any[]) => infer R ? R : never;infer R出现在函数返回类型的位置,TS 会尝试从待判断类型里推断出 R。不能写在普通类型别名或者泛型参数里。
Q3:为什么Array<any[]>.flat的返回类型是any[]?
回答思路:这是泛型参数推断在递归类型里的一个典型表现。flat方法的泛型签名是flat<A, D extends number = 1>(this: A, depth?: D): FlatArray<A, D>[],当A = any[]、D = 1时,FlatArray<any[], 1>的结果在类型层面会退化为any[],这本质上是由any的传染性导致的。所以工作中尽量少用any,否则泛型类型系统对你的约束会大打折扣。
Q4:泛型可以应用在 enum 上吗?
回答思路:泛型不能直接应用在 enum 声明上,因为 TS enum 本质是一个值。但你可以用类型工具在 enum 的基础上构造出相关的泛型类型,比如用keyof typeof enumName拿到枚举键的联合类型,再配合泛型工具做映射。
6.2 我踩过的泛型大坑
第一坑:泛型参数过度使用导致类型无法收敛。刚学会泛型的时候,喜欢不管三七二十一所有函数都加<T>,结果类型变得越来越宽泛,代码里到处都是unknown和类型断言。泛型的价值在于表达“类型关系”,如果一个函数里没有任何需要保持连通的类型关系,加泛型只会平添复杂度。
第二坑:条件类型分配性问题。直接看例子:
// 从数组提取元素类型时,如果不小心把泛型写在条件类型左侧,就会出问题 type ElementOf<T> = T extends any[] ? T[number] : T; type Foo = ElementOf<string | number[]>; // 是 string | number,还是 number?这里T如果是联合类型,条件类型会“分配”执行,即分别对string和number[]进行判断,再求联合。结果就是string | number。如果你不想分配,需要用方括号把泛型参数包住:
type ElementOf<T> = [T] extends [any[]] ? T[number] : T; type Foo = ElementOf<string | number[]>; // 结果是 number | string(因为分配仍在)严格说上面这个例子还需要更细致的处理才能完全避免分配性,但它足以说明问题:条件类型遇到裸类型参数时会自动拆成联合类型逐个判断,这是 TS 中非常容易踩的行为陷阱。面试中这是高级考点,能讲清楚分配律的人不多,但讲了会非常加分。
第三坑:泛型和 interface 的继承权限问题。泛型类的实例类型和静态类型不能通过泛型参数去访问,因为静态类型属于类本身,而泛型实例的类型参数在运行时并不存在。这个概念和 Java、C# 里的泛型擦除类似,后面我会单独聊。
6.3 泛型与类型谓词、重构的配合
泛型不只会和类型系统打交道,它还能和“类型谓词”(type predicate)配合,让你在写类型守卫时更精准:
// 不用泛型的写法,只能判断是不是数组,但没有细化元素类型 function isArray(value: unknown): value is unknown[] { return Array.isArray(value); } // 用泛型强化:传入 unknown,判断并收窄为 any[],然后元素类型由外部决定 function isArray<T>(value: unknown): value is T[] { return Array.isArray(value); } // 使用 const data: unknown = fetchSomething(); if (isArray<User>(data)) { data.map((u) => u.name); // data 被收窄为 User[] }这在处理接口返回的未知结构、表单校验等场景里特别有用。配合泛型,你不用在拿到数据后再用as强行断言,而是通过类型谓词把判断和收窄一步到位。
7. TypeScript 泛型与其他语言泛型的差异
7.1 Java、C# 的泛型与 TS 泛型到底差在哪
很多从 Java 或 C# 转过来的开发者,会带着其他语言的泛型思维理解 TS 泛型,结果经常遇到认知冲突。我来捋一下核心差异。
Java 的泛型是编译期概念,运行时会被“类型擦除”,所以你不能在运行时判断T到底是什么,也不能用new T()创建泛型实例。C# 的泛型是运行时真实存在的,你可以通过反射获取类型参数。而 TypeScript 的泛型本质上只存在于编译期,它比 Java 的擦除更彻底——因为 TS 编译成 JavaScript 后,泛型相关的语法会完全消失,运行时的T和类型信息根本不存在。
这意味着你必须接受几个事实:
- 你无法在运行时通过
typeof T获得任何信息。 - 泛型参数默认值只在类型层面生效,不影响运行时行为。
T在函数体内可以做条件判断、类型收窄,但这些逻辑会被擦除掉。
// 下面这种写法在 TS 里没有任何意义 function create<T>() { return new T(); // 报错:T 是类型参数,不能作为值使用 }而 Java 里你也不能new T(),因为擦除之后T不是真实类;C# 里因为泛型真实保留,可以配合Activator.CreateInstance做到。这个差异是理解不同语言泛型的一个重要分水岭。
7.2 结构类型系统 vs 标称类型系统
TS 的泛型约束走的是“结构类型系统”。也就是说,T extends HasLength并不要求 T 在名义上继承某个基类,只要它的结构上有length: number属性,就满足约束。
而 Java、C# 的泛型约束更偏向“名义类型系统”,通常需要一个真实的类型继承关系或接口实现,才能作为约束条件。举个直白的例子:
// TS 里几乎任何有 length 的类型都能传入 function getLength<T extends { length: number }>(value: T): number { return value.length; } getLength('hello'); // string,ok getLength({ length: 10 }); // 普通对象,ok在 Java 中,你声明T extends CharSequence,传入的类必须实际继承/实现了CharSequence接口,而不是说你的类有个length()方法就行。这个差异决定了 TS 泛型更灵活,但也更容易因为结构相近而导致类型“兼容”出错误。比如你有两个 interface,字段结构完全一样但含义不同,TS 会认为它们可以互相赋值,这有时候会带来隐蔽的 bug。
7.3 使用场景迁移建议
从 Java/C# 转 TS,我的建议是:
不要试图把其他语言的泛型约束思维直接搬过来。TS 的泛型更“结构化”,约束条件通常不是“继承什么类”,而是“拥有什么形状”。尽量用最小化的结构约束({ length: number }、keyof T、ArrayLike<T>)来表达你的需求,这样泛型的灵活性和安全性才能同时发挥出来。
在写第三方库的 d.ts 时同样如此,尽量依赖结构类型去描述约束,而不要依赖class继承关系,因为 d.ts 本质上描述的是形状。
8. 实操总结与避坑建议
8.1 一个真实案例:把 service 层用泛型收敛
我接手过一个后台管理系统,service 层每个模块都写着几乎一模一样的分页查询逻辑。后来我用泛型做了一个统一的分页 service:
class BaseService<T, CreateDTO = Partial<T>> { constructor(private apiPath: string) {} // 统一的列表查询,返回分页数据 async list(params: CommonQuery): Promise<PageResult<T>> { const res = await request<PageResult<T>>({ url: `${this.apiPath}/list`, method: 'GET', data: params, }); return res; } // 统一的详情查询 async detail(id: string): Promise<ApiResponse<T>> { const res = await request<ApiResponse<T>>({ url: `${this.apiPath}/${id}`, method: 'GET', }); return res; } // 统一的创建方法,入参可以是 CreateDTO async create(data: CreateDTO): Promise<ApiResponse<T>> { const res = await request<ApiResponse<T>>({ url: this.apiPath, method: 'POST', data, }); return res; } } // 每个业务模块只要继承并传入对应的实体类型即可 interface UserEntity { id: string; name: string; email: string; } interface CategoryEntity { id: string; name: string; slug: string; } const userService = new BaseService<UserEntity>('/api/users'); const categoryService = new BaseService<CategoryEntity>('/api/categories');用这套统一 BaseService 之后,每个业务模块几乎少写了一大半重复代码,而且类型是完整的。新增一个业务模块,只需要定义好对应的Entity类型和CreateDTO,然后实例化BaseService。这种用泛型做模板方法的模式,特别适合项目里存在大量同构 CRUD 接口的情况。
8.2 面试型泛型设计题:约束、推断、递归一起上
如果你准备面试或者想给团队做一次技术分享,我推荐一道“三合一”的泛型设计题,把约束、推断、递归全部串起来:
设计一个类型工具,输入一个嵌套的对象类型,输出它的所有值为函数类型的属性的名称:
type FunctionKeys<T> = { [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never; }[keyof T]; interface Actions { add: (a: number, b: number) => number; remove: (id: string) => void; description: string; nested: { run: () => void; value: number; }; } type Result = FunctionKeys<Actions>; // 'add' | 'remove'这道题同时考察了 keyof 的联合类型、条件类型、索引访问类型这三个核心概念。如果能顺手加上 infer 提取最终返回值,还能升级难度。个人经验是,能把这道题完整讲清楚的人,类型系统基本功基本靠谱。
8.3 我个人的一套泛型使用守则
写了好几年 TS 项目,我把自己踩坑总结出来的泛型使用守则分享给你:
- 能用
keyof T约束键的,不要写死 string;能用T[K]获取值类型的,不要用 any。 - 泛型的边界越小越好,比如
T extends Record<string, unknown>比T好,T extends { id: string }比T extends object好。 - 如果一个泛型参数只出现一次,比如
function fn<T>(x: T): void,那它往往是无意义的。真正的泛型应该在某些参数、返回值、或者其他类型工具中多次出现,形成“类型关系”。 - 工具类型优先使用内置工具,不够时才自定义。自定义的时候可以用条件类型和 infer,但别忘了加注释,否则团队其他人维护起来想骂人。
- 不要在
.d.ts的 global 里放太多泛型,全局泛型工具要收敛,按需 export 到你需要的模块。
8.4 常犯错误速查表
| 错误写法 | 问题原因 | 正确写法 |
|---|---|---|
function fn<T>(x: T): any | 返回 any 把泛型关系切断了 | function fn<T>(x: T): T |
function fn<T extends any[]>(x: T) | T被过度约束成数组,不够通用 | 按需约束,如T extends readonly unknown[] |
type X<T> = T extends string ? 1 : 2直接拿联合类型用 | 联合类型被条件类型分配 | 用[T] extends [string]或者先T[] extends string[]规避分配 |
泛型类里直接访问T的静态属性 | 类型参数在运行时不存在 | 通过构造函数传入相关依赖或映射表 |
T[K]中使用非keyof T的 K | K 不在 T 的键上,报索引类型错误 | K extends keyof T约束 |
9. 实际使用中的三个补充经验
9.1 泛型不要“传染”到不必要的地方
有人写函数喜欢任何场景都先加一个<T>,直到后来某个团队成员维护到一个函数:
function isEqual<T>(a: T, b: T): boolean { return a === b; }这个函数本质上只需要unknown参数就够了,加泛型反而让调用方困惑:isEqual<number>(1, 2)和isEqual(1, 2)有什么区别?没有。泛型应该保留在能形成类型关系的场景里,比如参数 A 和参数 B 有关联、或者返回值由参数类型决定。如果一个泛型参数在签名里“孤零零”地出现一次,赶紧删掉它。
9.2 利用泛型做更好的“函数重载”
TS 支持函数重载,但泛型往往能更优雅地解决重载场景。举个实际的例子:你有一个函数,传 string 返回 string,传 number 返回 number:
// 重载方案 function clone(value: string): string; function clone(value: number): number; function clone(value: string | number): string | number { return value; }如果类型组合多了,重载会越写越多。泛型方案就一行:
function clone<T extends string | number>(value: T): T { return value; }这就是泛型相对于重载的不可替代价值:当类型空间很大或无限时,重载无法穷举,泛型一次解决。在写工具函数、处理数据映射、格式化器这类场景,优先考虑泛型,不要思维固化在手动枚举每一种重载签名上。
9.3 性能视角:慎用深层递归类型
条件类型配合 infer 做递归很香,但一个项目里遍地都是深递归工具类型,编辑器会变卡。比如前面那个UnwrapPromise,如果你在很多地方嵌套调用PageResult<Promise<User>>之类复杂的类型,TS 编译器可能要计算很久。我的习惯是,类型工具的递归深度控制在个位数,复杂的类型计算不要放在频繁调用的工具函数上,除非你确定团队的类型检查速度能承受。
而且,过于复杂的类型工具已经变成了“类型体操”,业务代码的可读性会直线下降。给团队定的经验值是:类型工具的表达式能在一屏内读完,超过就拆开写中间类型。这不是压制炫技,而是对协作负责。
我个人在实际项目里对泛型的体会是:它不是一门需要背的语法,而是一种“用类型描述关系”的思维方式。你越想用as any绕过类型时,越应该停下来想想能不能用泛型表达这个关系;你越是觉得某个接口或者组件到处复制粘贴类型定义时,越是该抽一个泛型工具出来。把泛型用到顺手之后,你再回头看自己的代码,会发现类型不再是约束,而是从设计阶段就嵌入的一套文档。好的类型系统不该让你觉得啰嗦,而应该让你觉得写代码的时候后背有人托着。这篇内容如果你能自己动手把每个代码块都敲一遍,再把最后的错误速查表过一遍,泛型这块面试和日常使用基本就稳了。