十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

TypeScript 泛型完全指南:从基础语法到实际项目封装

TypeScript 泛型完全指南:从基础语法到实际项目封装 每个 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): PromiseUser { return http.get(/users/${id}); } async function fetchOrder(id: string): PromiseOrder { 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 firstElementT(arr: T[]): T | undefined { return arr[0]; }T这里的T是一个“类型参数”函数体内部不知道该参数具体是什么但是它知道返回值的类型一定等于数组元素类型这层关系没有丢失。调用时你可以由编译器自动推导也可以显式指定const num firstElement([1, 2, 3]); // T 推导为 number const str firstElementstring([a]); // 显式指定 T 为 string如果不用泛型把这个函数写成固定返回any那类型关系就断了如果固定返回number那字符串数组又用不了。泛型做的是第二层抽象把“值参数化”的方式同样用到“类型”上在编译期建立一个可以由调用方填充的占位符然后在填充的瞬间完成全面的类型校验。这是 JS 根本没有的能力也正是泛型值得单独花一篇去讲的原因。2. 泛型基础语法函数、约束、默认值一次说清2.1 泛型函数两种调用方式与类型推断边界泛型函数最常见的长相是下面这种function identityT(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 saveT 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 getCachedT extends { id: string }(list: T[], id: string): T | undefined { return list.find((item) item.id id); }正是通过extends你既获得了复用的自由又保住了操作对象成员时的安全。2.3 默认泛型参数处理多参数的技巧函数可以有默认值泛型类型参数也同样可以指定默认类型。默认泛型主要在“调用方不传参时也能有兜底”的场景特别有用。function createArrayT string(length: number, value: T): T[] { return Array.from({ length }, () value); } const a createArray(3, 1); // number[]推导优先 const b createArraystring(3, x); // string[]默认值最常出现在封装第三方库或基础组件时。比如一个setState风格的功能前期大多数组件存的状态是普通对象但个别组件确实要存别的结构interface StoreOptionsTState Recordstring, unknown { initialState: TState; }当你定义调用方如果不指定泛型它可以按默认类型工作指定了就完全以指定类型为准。多泛型参数的默认值还有一个规则要注意一旦某个类型参数给了默认值它右边的类型参数最好也都有默认值否则使用时不完整指定就会报错。2.4 泛型接口与泛型类把模板落到更多地方类型参数不只属于函数。接口和类同样可以定义自己的泛型。最常见的响应结构封装会写成这样interface ApiResponseT { code: number; message: string; data: T; } interface UserProfile { userId: string; nickname: string; } function getUserProfile(): PromiseApiResponseUserProfile { return http.get(/profile); }这个ApiResponseT就像一个通用了所有响应数据的模板T换成什么data字段就是什么。类也可以有自己的泛型参数典型实现就是一个极简仓库interface Entity { id: string; } class MemoryRepositoryT extends Entity { private readonly items new Mapstring, 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 HttpResponseT { code: number; message: string; data: T; } async function httpGetT(url: string): PromiseT { const response await fetch(url); const body: HttpResponseT 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 httpGetCommentItem[](/comments);httpGetT有两个耐人寻味的地方。第一T描述的是真正业务里需要的数据类型而不是外层的code/message/data包装第二不管有多少接口这一层逻辑只写一次。你想在返回数据前统一做 cancel token、统一埋点、统一错误上报都只用动这一处。如果你用的是 axios也可以做类似封装import axios, { AxiosResponse } from axios; async function requestT(config: { url: string; params?: Recordstring, unknown }): PromiseT { const response: AxiosResponseHttpResponseT await axios.request(config); return response.data.data; }这里你只需要关心“T 和最终业务数据的对应关系”网络层细节全部封装在函数内部这比面向接口复制代码要优雅得多。3.2 从响应结果中安全提取嵌套字段真实后端返回的数据很少永远扁平的比如分页结构interface PageResponseT { list: T[]; page: number; pageSize: number; total: number; } async function fetchPageT(page: number): PromisePageResponseT { return requestPageResponseT({ url: /items, params: { page } }); }于是fetchPageUser()返回的list就是User[]fetchPageOrder()就是Order[]。这种结构很容易扩展就算未来再加一层“statistics”字段你的泛型参数也只要在PageResponseT, U里再加一个即可。这类设计的核心收益不是把类型“从 A 换成 B”而是当后端改动数据结构时类型检查会立刻告诉你的业务代码哪里受影响而不是在线上跑挂了才被用户发现。类型安全最重要的红利就在这里。3.3 仓储类、状态容器与泛型实例除了请求层项目里常见的状态容器也适合泛型。class StateStoreTState { private state: TState; constructor(initialState: TState) { this.state initialState; } getState(): TState { return this.state; } patch(partial: PartialTState): void { this.state { ...this.state, ...partial }; } } interface CartState { items: string[]; couponCode?: string; visible: boolean; } const cartStore new StateStoreCartState({ items: [], visible: false }); cartStore.patch({ couponCode: SAVE10 }); // 合法 cartStore.patch({ items: 123 }); // 报错items 必须是 string[]这里还顺带用到了内置工具类型PartialTState它把TState里所有属性都变成可选项。你在编辑大型对象时可以只传部分字段而不需要把整个对象都重建一遍。为什么要强调这个例子因为它说明泛型之间可以自由组合函数、接口、类、内置工具类型彼此嵌套最终构成一套自己的业务约束体系。如果把 store 做成一个函数而非类泛型同样可以胜任function createStoreTState(initial: TState) { let state initial; return { get: (): TState state, set: (next: TState) { state next; } }; } const userStore createStoreUser | 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 MyPartialT { [P in keyof T]?: T[P]; };keyof T的意思是取出 T 的所有属性名组成联合类型P in keyof T表示遍历这个联合类型中的每一个属性名: T[P]表示新对象的属性值仍然保持原来的类型。末尾的?让所有属性变成可选项。这就是映射类型它和for...in很像只是遍历的是“类型层面”。PickT, K的实现也很简单type MyPickT, 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 PickArticle, title | authorId;整个过程相当直观告诉编译器“我只保留 title 和 authorId 两个属性”得到的ArticlePreview会精确包含这两个字段及对应类型。手写一遍之后再查资料时会有非常明确的思维方向。4.3 条件类型和 infer从复杂类型中抽取片段infer是泛型进阶中比较绕但极其常用的一个关键字。它允许你在条件类型里声明一个待推断的类型变量然后TS在匹配过程中自动填充它。典型的例子是取 Promise 的返回值类型type AwaitedT T extends Promiseinfer R ? R : T; type Result AwaitedPromisestring; // string type Result2 Awaitednumber; // numberT extends Promiseinfer R的意思是如果 T 可以匹配上某种 Promise那么其中的 R 到底是多少由编译器去推断。比如T是PromiseCustomer编译器就自动得出R Customer。紧接着还以这个 R 替换三元分支中true一侧的表达式。数组元素类型也可以用同样的思路抽取type ElementOfT T extends Arrayinfer E ? E : never; type Item ElementOfstring[]; // string如果你在项目里经常用 Redux、React Query 或者各种第三方库一定会遇到需要从某个复杂函数的返回值里“抠”出类型的场景。手动把这个函数类型写一遍非常繁琐用ReturnType加Awaited包裹一下就解决了type Result AwaitedReturnTypetypeof fetchUserProfile;这就是深入理解“Promise infer ReturnType”嵌套的真实收益。4.4 条件类型分配特性为什么会得到联合类型条件类型有一个很容易踩坑的分配特性中文社区常叫它“分布式条件类型”。当 T 是一个联合类型时条件类型会把联合类型的每个成员单独过一遍条件判断再把结果合并成联合类型。type ToArrayT T extends string ? string[] : number[]; type A ToArraystring | number; // string[] | number[]如果你不希望它逐个成员生效而是想整个联合类型作为一个整体去判断可以给条件类型中检查的类型包一层[]type ToArrayNonDistributiveT [T] extends [string] ? string[] : number[]; type B ToArrayNonDistributivestring | number; // number[]这个“要不要分布式”的差异会导致同一个类型工具在不同联合类型下的结果完全不同。在这上面栽过跟头的开发者不少所以我建议你在自定义复杂的条件类型时多测试几个联合类型输入确认结果是否符合直觉同时给这些类型写一些注释不然三个月后你自己回来看也会懵。5. 泛型实践规范、高频报错与排查技巧5.1 类型推断失败时先看看是不是缺了约束泛型最常见的第一类报错是“TS 无法确定 T 是什么”。看这段代码function getFirstT(list: T[]): T { return list[0]; }如果你关闭了strictNullChecks它可能不报错但更严格的配置下由于list[0]在运行时可能是undefinedTS 会提示返回值可能不是 T。很多时候不是泛型“推断不出来”而是你操作对象的范围太宽。解决方案无非两种允许返回T | undefined或者在调用处确保一定有值function getFirstT(list: T[]): T | undefined { return list[0]; } function getFirstStrictT(list: T[]): T { if (list.length 0) { throw new Error(list is empty); } return list[0]; }这个问题我在项目里看得非常多。新手觉得是“泛型写复杂了导致报错”实际上成因是数组下标访问本身存在边界风险TS 在严格模式下宁可报错也不放行。想减少这类报错核心不是删掉泛型而是先把函数要考虑的空值情况考虑完整。5.2 显式传泛型和推导结果不一致第二种高频问题是显式指定了类型参数之后传入实参无法满足约束。例如function logAndReturnT extends string(value: T): T { console.log(value); return value; } logAndReturnstring(hello);这个没问题。但如果你写function guessValueT(value: number | string): T { return value as unknown as T; }这种“硬把泛型当任意类型转换”的做法很容易在调用方完全指定错误的类型时逃过编译直到运行时才报错。它本质上是用as unknown as T暴力跳过了类型系统和使用any的后果是一样的。正确做法通常是让类型参数参与推导过程或者通过约束缩小范围function guessValueT extends number | string(value: T): T { return value; }如果必须要做类型转换请把它收敛在一个较小的内部函数里并添加注释说明为什么这里需要as。直接暴露一个会强制抹平类型的泛型函数会让整个链路失去保护是泛型使用上最常见的“假安全”写法。5.3 泛型与默认值、重载选择有关联时容易出幻觉问题有人喜欢这样写function createItemT(defaultValue?: T): T { return defaultValue ?? ({} as T); }这段代码在strictNullChecks下并不会报错但调用时很容易产生误导const item createItem(); // item 的类型被推断为 unknown而不是理想中的 {}由于没有传参TS 无法推导 T就只能把T当作unknown。如果最终赋给某个确定类型的变量可能还会报无法转换。这种“使用默认值”的场景其实不太适合用泛型硬撑尤其是当返回值可能是空对象时。更稳妥的写法是泛型约束加默认对象或者默认函数function createItemT extends object Recordstring, 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没问题一但泛型参数超过两个强烈建议取名更明确。三控制泛型数量。一个函数的泛型参数超过三四个调用的可读性就会严重下降。这时你通常应该考虑新增一个描述型接口把多个类型参数收拢进一个OptionsT, 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 和分布式条件类型。泛型真正的价值在于它让你能把公共逻辑和差异类型解耦让“结构不变、类型不同”的代码只维护一份同时每个调用点都能获得精确的类型提示。这种收益在项目规模变大后会越来越明显。从下一个重复代码片段开始动手抽一个泛型版本吧你会在第一次调用时体会到那种“写一次处处都安全”的舒畅感。
返回列表