想象你走进一间高端定制厨房,里面摆着一台“万能料理机”。这台机器本身不关心你放进去的是番茄、牛肉还是冰块,只要换上对应的刀头,它就能切片、绞碎、打冰。TypeScript 的泛型就是这台料理机——我们写一次逻辑,却能喂进去各种类型的“食材”;而约束则是装在机器入口的“规格检测器”,保证只有符合刀头规格的食材才能进入,既灵活又安全。本文从泛型函数讲起,一路经过泛型类、extends 约束、keyof、条件类型和 infer,最后用真实项目案例告诉你如何把“万能料理机”端进自己的厨房。

岗位关联

学会泛型,相当于给你的 TypeScript 代码装上“可换刀头 + 安全闸门”:写一次逻辑服务多种类型,还能在编译期拦下类型错误。这是中高级前端面试与大型项目重构的必备技能。

目录
  1. 可视化演示
  2. 泛型函数:料理机的第一把刀头
  3. 泛型类:带储物格的料理机主体
  4. 泛型约束:刀头规格检测器
  5. keyof 操作符:食材部位一览表
  6. 条件类型:智能菜谱自动分流
  7. infer 提取:从真空包装里取出食材
  8. 实际应用:做一桌泛型大餐
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

通过泛型参数化、extends约束、keyof/infer提取三个模式,直观展示泛型类型的流动、约束边界和类型推导机制。

本节配有交互动效(进入视口后自动加载,约 3KB)。

泛型参数化机制 类型参数T作为占位符,调用时确定具体类型 同一泛型可接受string、number、object等不同类型 function fn<T>(x:T):T 参数化多态让代码复用且类型安全

泛型函数:料理机的第一把刀头

万能料理机最迷人的地方在于:刀头是通用的。今天切土豆,明天打豆浆,机器主体不用换,只要把对应刀头装上去。TypeScript 的泛型函数就是这把“通用刀头”——它先用一个占位符 T 声明“这里可以放任意类型”,等真正调用时再装上具体的“食材”。

用一个最经典的 identity 函数来理解。普通版本要写死 number 或 string,而泛型版本只写一次:

当调用 identity(42) 时,编译器自动把 T 推断为 number;调用 identity('hello') 时,T 变为 string。机器本身没变,但每次接的“食材”都不痛——这就是泛型函数“换刀头”的魔力。你不需要为每种类型复制一段逻辑。

function identity<T>(arg:T):T{ return arg;}\displaystyle \text{function } \text{identity}<T>(arg: T): T \{ \text{ return } arg; \}
  1. 声明类型参数<T> 告诉编译器,这里有一个“待定类型”,先占个坑。
    <T><T>
  2. 参数类型arg: T 表示入参的类型就是这个待定类型,所以传什么类型,参数就是什么类型。
    arg:T\text{arg}: T
  3. 返回值类型: T 保证返回值的类型和入参一致,换刀头后“切进去什么,出来还是什么”。
    :T: T
  4. 函数体return arg 原样返回,泛型不改变运行时的数据,只约束编译期的类型关系。
    return arg\text{return } arg

泛型类:带储物格的料理机主体

料理机不能只有刀头,还得有容纳食材的料理杯。这个杯子可以装番茄、牛肉、冰块,但每次只装一种,而且装了什么,取出来就该是什么。TypeScript 的泛型类就像这个带“类型标签”的料理杯:整个类共享一个类型参数 T,所有成员都跟着这个参数走。

实例化时写 new Box<number>(),这个料理杯就只装数字;写 new Box<string>(),它就只能装字符串。静态成员无法使用类的类型参数 T,因为静态成员属于“机器本身”而不是“某一个料理杯”,这就像电源按钮不能属于某一杯食材——这是泛型类里最容易踩的坑,后面我们会专门提到。

class Box<T>{ private content!:T; set(v:T):void; get():T}\displaystyle \text{class } \text{Box}<T> \{ \text{ private } \text{content}!: T; \text{ set}(v: T): \text{void}; \text{ get}(): T \}
  1. 类名后声明类型参数class Box<T> 让整个类都可以使用 T,就像料理杯上贴了“本次容量”标签。
    class Box<T>\text{class Box}<T>
  2. 成员变量类型private content!: T 表示这个私有储物格存的是类型 T 的内容。
    content:T\text{content}: T
  3. 写入方法set(v: T) 只接受 T 类型的食材,防止把牛肉塞进“只能装番茄”的杯子。
    set(v:T)\text{set}(v: T)
  4. 读取方法get(): T 保证取出来的类型和装进去时一致,不会出现“装番茄拿出来却是红酒”的意外。
    get():T\text{get}(): T

泛型约束:刀头规格检测器

料理机虽然万能,但也不是所有东西都能往里塞。比如你换了一个“切丝刀头”,就不能去绞骨头,否则刀头会崩。TypeScript 的 extends 约束就是装在机器入口的“规格检测器”:它说“只有具备了某些特征的食材才允许进入”。

最常见的约束是要求类型必须有 length 属性,比如字符串、数组、对象等。

如果尝试调用 getLength(42),数字类型没有 length,规格检测器会立刻拦截,编译直接报错。约束并不意味着功能变弱,反而让泛型在灵活的基础上变得更安全——没有检测器的料理机,你敢放心用吗?

function getLength<T extends {length:number}>(arg:T):number{ return arg.length;}\displaystyle \text{function } \text{getLength}<T \text{ extends } \{ \text{length}: \text{number} \}>(arg: T): \text{number} \{ \text{ return } arg.length; \}
  1. 约束关键字T extends { length: number } 表示 T 必须是“具备 length 属性的形状”的子类型。
    T extends {length:number}T \text{ extends } \{ \text{length}: \text{number} \}
  2. 参数类型arg: T 传入的参数被限定在该约束范围内,比如字符串或数组。
    arg:T\text{arg}: T
  3. 访问属性arg.length 由于约束保证了这个属性存在,编译器不再报错。
    arg.length\text{arg.length}
  4. 返回值: number 返回的是长度数值,与料理机的“切丝长度”一致。
    number\text{number}

keyof 操作符:食材部位一览表

厨师拿到一块肉,先要看它有哪些部位可以切——里脊、五花、排骨。TypeScript 里,对象也有自己的“部位清单”,keyof 就是印在包装上的食材部位一览表:它把一个对象类型的所有键提取出来,组成一个联合类型。

keyof 常常和索引访问一起用:type Age = T['age'] 就能取出某个键对应的值类型。映射类型 type Optional<T> = { [K in keyof T]?: T[K] } 更是把每个属性都变成可选,就像给每块食材贴上“可用可不用”的标签。掌握了 keyof,你就拿到了料理机最顺手的“解剖工具”。

type Keys=keyof T;(T={name:string,age:number}⇒Keys=’name’∣’age’)\displaystyle \text{type } \text{Keys} = \text{keyof } T; \quad (T = \{ \text{name}: \text{string}, \text{age}: \text{number} \} \Rightarrow \text{Keys} = \text{'name'} \mid \text{'age'})
  1. keyof 操作符keyof T 读取 T 的所有键名,得到 'name' | 'age' 这样的联合类型。
    keyof T\text{keyof } T
  2. 结果赋值type Keys = ... 把提取出的联合类型存成一个新类型别名。
    type Keys=keyof T\text{type Keys} = \text{keyof } T
  3. 等价关系'name' | 'age' 表明 keyof 的结果是字面量联合,类似给每块食材贴上标签。
    ’name’∣’age’\text{'name'} \mid \text{'age'}

条件类型:智能菜谱自动分流

高级料理机会根据食材自动切换模式:是硬的,就选“切块”;是软的,就选“搅拌”。TypeScript 的条件类型同样会在类型层面做“智能判断”:T extends U ? A : B。如果 T 可以赋值给 U,就取 A,否则取 B。

更有趣的是“分发行为”:当 T 是联合类型时,条件类型会分别对每个成员做判断再合并结果。比如 IsArray<string | number[]> 会先判断 string,再判断 number[],最后得到 false | true,等价于 boolean。这就像料理机把一袋混装食材倒进分拣槽,每个食材独立过闸,最后汇总到对应出口。Exclude、Extract、NonNullable 这些内置工具类型,全是基于条件类型搭积木搭出来的。

type IsArray<T>=T extends any[]?true:false\displaystyle \text{type } \text{IsArray}<T> = T \text{ extends } \text{any}[] ? \text{true} : \text{false}
  1. 条件判断T extends any[] 是判断条件,即“T 是不是数组”。
    T extends any[]T \text{ extends } \text{any}[]
  2. 真分支? true 表示如果满足条件,结果类型是 true。
    true\text{true}
  3. 假分支: false 表示不满足条件时结果是 false。
    false\text{false}

infer 提取:从真空包装里取出食材

超市买回来的冷冻牛排,外面总有一层真空包装袋。要处理牛排,得先拆掉包装。TypeScript 的 infer 就负责“拆包装”:它在条件类型里先声明一个待推断的变量,然后从复杂的类型结构里把真实类型提取出来。

最经典的用法是提取函数返回值类型:

同样的思路还能提取函数参数类型 Parameters<T>,甚至通过递归模板把 Promise 层层剥开:Promise<Promise<string>> 也能拆出最内层 string。infer 必须出现在条件类型的 extends 子句中,不能单独使用——就像“拆包装”这个动作必须发生在检测食材的过程中,而不是凭空拆。

type ReturnType<T>=T extends (…args:any[])⇒infer R?R:never\displaystyle \text{type } \text{ReturnType}<T> = T \text{ extends } (\ldots args: \text{any}[]) \Rightarrow \text{infer R} ? R : \text{never}
  1. 约束为函数T extends (...args: any[]) => ... 判断 T 是不是一个函数类型。
    T extends (…args:any[])⇒…T \text{ extends } (\ldots args: \text{any}[]) \Rightarrow \ldots
  2. infer 声明infer R 表示“我先不管 R 具体是什么,等匹配完成后告诉你”——这就是拆开包装取出食材的过程。
    infer R\text{infer R}
  3. 捕获结果? R 如果匹配成功,R 就是函数返回值类型。
    R\text{R}
  4. 兜底类型: never 如果不是函数,则返回 never,表示“这里没有食材”。
    never\text{never}

实际应用:做一桌泛型大餐

前面六把“刀具”都备齐了,现在我们把它们端上真正的餐桌。在真实项目里,泛型最常见的舞台是“通用请求封装”。接口返回结构通常固定为 { code, data, msg },但 data 的类型每个接口都不一样。用泛型写一个封装,代码复用率达到最高。

调用时 request<UserInfo>('/user/info'),返回值里的 data 会自动带上 UserInfo 的全部属性提示,根本不需要手动断言。再配合 keyof 写一个 get<K extends keyof T>(obj: T, key: K): T[K] 的工具函数,访问对象属性时,返回值类型会根据传入的键名精确推导。工具函数、组件、接口封装……这就是泛型在真实项目里的一桌大餐。

type ApiResp<T>={code:number,data:T,msg:string};request<T>():Promise<ApiResp<T>>\displaystyle \text{type } \text{ApiResp}<T> = \{ \text{code}: \text{number}, \text{data}: T, \text{msg}: \text{string} \}; \quad \text{request}<T>(): \text{Promise}<\text{ApiResp}<T>>
  1. 响应类型ApiResp<T> 把 data 字段的类型设为 T,其他字段固定。
    ApiResp<T>={code:number,data:T,msg:string}\text{ApiResp}<T> = \{ \text{code}: \text{number}, \text{data}: T, \text{msg}: \text{string} \}
  2. 请求函数request<T>() 声明一个泛型函数,调用时指定期望的返回数据类型。
    request<T>()\text{request}<T>()
  3. 返回结构: Promise<ApiResp<T>> 返回一个 Promise,内部 data 类型由 T 决定。
    Promise<ApiResp<T>>\text{Promise}<\text{ApiResp}<T>>

AI 应用场景

通用 API 请求封装

阶段:数据接入与类型安全

解决的问题:传统封装用 any 表示响应数据,接口一变,所有调用处都可能崩,排查成本极高。泛型封装把 data 的类型留给调用方指定,编译期就能发现字段拼错或类型不匹配。

具体案例:前端团队接入人工智能平台的模型列表接口,使用 ApiResp<ModelInfo> 泛型封装后,响应里的 modelName、params 等字段都有自动补全;后端调整字段时,编译错误能精准定位到受影响的调用点。

类型安全的表单校验工具函数

阶段:配置驱动的校验逻辑

解决的问题:表单校验常常出现“配置写错 key 但运行到用户提交才报错”的尴尬。借助 keyof T 与 infer,可以把校验规则的对象 key 与表单数据类型绑定,提前暴露错误。

具体案例:一个 AI 训练参数配置表单,使用 Validator<T> 泛型工具,配置 rules: {[K in keyof T]: Rule} 后,新增字段时编译器强制要求补全对应校验规则,避免漏校验。

Python 演示

python38 行
from typing import TypeVar, Generic, Sequence, Optional T = TypeVar('T')Sized = TypeVar('Sized', bound=Sequence)  def first_element(items: Sequence[T]) -> Optional[T]:    return items[0] if items else None  class Container(Generic[T]):    def __init__(self, value: T) -> None:        self._value = value     def get(self) -> T:        return self._value     def set(self, value: T) -> None:        self._value = value  def longest(items: Sequence[Sized]) -> Optional[Sized]:    if not items:        return None    result = items[0]    for item in items[1:]:        if len(item) > len(result):            result = item    return result  if __name__ == "__main__":    box = Container(42)    print(box.get())    box.set(7)     fruits = ["apple", "kiwi", "strawberry"]    print(longest(fruits))
目的:用 Python 的 typing.TypeVar 和 Generic 模拟 TypeScript 泛型与约束思想,展示同样的“容器复用”与“长度约束”。
  1. T = TypeVar('T')创建类型变量,相当于 TypeScript 里的 <T> 占位符。
  2. Sized = TypeVar('Sized', bound=Sequence)给类型变量加约束,要求传入元素必须具备序列能力,对应 extends {length:number}。
  3. class Container(Generic[T])声明泛型类,让整个类共用同一个类型参数。
  4. def longest(items: Sequence[Sized]) -> Optional[Sized]:在函数签名里使用受约束的类型变量,保证 len() 可用。
  5. box = Container(42)实例化时自动推断 T 为 int,类似 TS 泛型类的类型推断。

常见误区

误区泛型不就是写个 `any` 吗?都一样,反正都能接收任意类型。

正解any 等于把料理机的安全检测器彻底拆掉,食材进去之后变成了“不可知的黑箱”。泛型则保留了调用处的类型关系:传入 number 返回值就是 number,传入 string 返回值就是 string。用 any 会失去自动补全和编译期检查,等于放弃了 TypeScript 最大的价值。

为何常见:初学阶段遇到类型报错,第一反应就是“用 any 把它变宽”,久而久之把 any 当成了万能药。

误区泛型能在运行时帮我检查类型,防止错误数据进入。

正解TypeScript 的泛型是纯编译期能力,代码编译后类型信息会被完全擦除。运行时根本没有 T 存在,它只是编译期的“安全闸门”。真正的运行时校验需要额外代码,比如 zod 或手动判断。

为何常见:因为编译报错和运行时错误看起来都是“报错”,初学者容易把两个阶段混为一谈。

误区约束越多越安全,所以把泛型约束写得巨细无遗,比如 `T extends { id: number; name: string; age: number }`。

正解泛型的价值是“复用”,约束应该只限定必需的属性。就像料理机的“规格检测器”只需要检查“能不能切”,不需要检查食材的颜色和产地。约束写得太死,函数就退化成普通函数,换不了几种食材了。

为何常见:把个别调用场景的需求误当成所有场景的公共约束,不知不觉中把菜谱细节写进了机器规格。

误区泛型类里定义的静态成员可以直接使用 `T`,毕竟整个类都能看到这个类型。

正解类的静态成员属于类本身,而不是某个实例。Box<number> 和 Box<string> 共享同一个静态成员,如果静态成员的类型取决于 T,那它到底算 number 还是 string 就无法确定了。所以 TypeScript 明确禁止静态成员使用类的类型参数。

为何常见:日常开发中读写实例成员多、读写静态成员少,很少意识到静态成员不在“同一个料理杯”里。

面试题

Q1. 泛型函数 function identity<T>(arg: T): T 的核心作用是什么?

单选题 · easy

  • A.让函数支持任意类型并保持类型关系← 正确答案
  • B.让函数在运行时检查参数类型
  • C.将参数强制转换为字符串
  • D.禁止传入数字类型

答案:A。泛型函数用类型参数 T 建立入参和返回值的联系,调用时自动推断具体类型。它不像 any 那样丢弃类型信息,而是保留传入类型与返回类型的一致性;B 是运行时机制,C/D 都是错误的理解。

Q2. 以下哪个泛型约束写法是正确的?

单选题 · mid

  • A.function f<T extends { length: number }>(x: T)← 正确答案
  • B.function f<T length: number>(x: T)
  • C.function f<T>{length:number}(x: T)
  • D.function f<T: {length:number}>(x: T)

答案:A。泛型约束必须使用 extends 关键字,后面跟一个类型形状,表示 T 必须是该类型的子类型。B 少extends且语法错误,C 的约束位置写在了函数体处,D 的冒号是其他语言的写法。

Q3. keyof T 的作用是什么?

单选题 · mid

  • A.提取对象 T 的所有键名,组成联合类型← 正确答案
  • B.提取对象 T 的所有值,组成联合类型
  • C.删除对象 T 的属性
  • D.在运行时遍历对象的键

答案:A。keyof 是类型层面的键提取操作符,把对象类型的键名转换为字面量联合类型,比如 keyof {a:1,b:2} 得到 'a'|'b'。B 是取值类型,C 是运行时操作,D 是 JavaScript 的 Object.keys,都不对。

Q4. 条件类型 type IsArray<T> = T extends any[] ? true : false,IsArray<number[]> 的结果是?

单选题 · mid

  • A.true← 正确答案
  • B.false
  • C.any
  • D.编译错误

答案:A。条件类型 T extends U ? X : Y 会判断 T 是否可赋值给 U。number[] 满足 any[] 的约束,所以走真分支返回 true。这就是“智能菜谱”根据食材自动分流的基本原理。

Q5. 关于泛型类的说法,哪些正确?

多选题 · mid

  • A.类可以声明自己的类型参数← 正确答案
  • B.静态成员不能使用类的类型参数← 正确答案
  • C.实例化时需要显式指定或自动推断类型参数← 正确答案
  • D.泛型类必须被继承才能使用

答案:ABC。泛型类在类名后声明类型参数,实例成员可以引用 T,但静态成员属于类本身不能使用 T。实例化时可以写 new Box<number>() 也可以由构造函数参数推断类型。泛型类不需要继承,直接实例化即可,所以 D 错。

Q6. infer 关键字通常出现在哪里?

单选题 · hard

  • A.条件类型的 extends 子句中← 正确答案
  • B.函数返回值类型前面
  • C.类的构造方法里
  • D.接口的 extends 子句中

答案:A。infer 只能在条件类型 T extends U ? X : Y 的 U 部分使用,用来声明一个待推断的类型变量,比如提取函数返回值。它不能脱离条件类型独立使用,B/C/D 的使用位置都是语法错误。

Q7. type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never,ReturnType<() => string> 的结果是?

单选题 · mid

  • A.string← 正确答案
  • B.never
  • C.any
  • D.Function

答案:A。T 被匹配成 () => string,infer R 会捕获返回值类型 string,所以结果类型是 string。这就是“拆开真空包装取出真实食材”的过程,never 只在 T 不是函数时出现。

Q8. 关于泛型与 any 的区别,哪些描述正确?

多选题 · hard

  • A.泛型保留调用处的类型关系,any 会丢失类型关系← 正确答案
  • B.泛型是编译期能力,any 是类型逃生舱← 正确答案
  • C.使用泛型能获得自动补全和编译期检查← 正确答案
  • D.泛型只能用于函数,不能用于类

答案:ABC。泛型用占位符建立参数、返回值等类型关联,any 则完全放弃检查。两者都是编译期概念,但 any 会在代码中传播“不可知”。泛型不仅能用于函数,也能用于类和接口,所以 D 错。

Q9. class Box<T = string> 中默认类型参数的作用是什么?

单选题 · easy

  • A.实例化时未指定类型则 T 默认为 string← 正确答案
  • B.T 永远只能是 string
  • C.让 T 在运行时可以被修改
  • D.等价于 Box<string | number>

答案:A。默认类型参数为 T 提供了兜底类型,new Box() 不写类型时,T 就是 string。但如果显式写 new Box<number>(),仍然可以覆盖默认值,所以 B/C/D 都不对。

Q10. 泛型约束 T extends { length: number } 的核心价值是什么?

单选题 · hard

  • A.限定类型参数必须具备 length 属性,保证函数内可安全访问← 正确答案
  • B.让泛型只能应用于 class 类型
  • C.把 T 强制转换为 number 类型
  • D.禁止字符串和数组作为参数

答案:A。extends 在约束位置表示“必须是指定类型的子类型”,{length:number} 要求类型具备 length 属性。这样函数体内就能安全调用 arg.length,字符串和数组依然可以用,因为它们都有 length,所以 C/D 错误,B 与约束本身无关。