我死磕了很久才搞懂的 TypeScript 泛型,今天用"模具"讲明白了

在这里插入图片描述

写在前面

如果你跟我一样,泛型这玩意儿写了无数遍,每次照着语法抄一遍能跑起来,但合上电脑就完全说不清"它到底在干嘛"——这篇文章就是写给你的。

不讲术语,先讲一个比喻。


一、先理解:泛型到底解决什么问题

假设你要写一个函数,取数组的第一个元素:

function getFirst(arr: number[]): number {
  return arr[0]
}

这个函数只能处理 number 数组。如果你想处理字符串数组呢?逻辑一模一样,但你得再写一个:

function getFirstString(arr: string[]): string {
  return arr[0]
}

代码逻辑完全相同,只是类型不一样,却要复制粘贴一份。这就是泛型要解决的问题:逻辑相同,类型不同,怎么避免重复造轮子。


二、一个比喻:泛型就是"模具"

我后来理解泛型,是靠这一个画面:

泛型就是一个模具,不是具体的杯子,而是"杯子的形状"。
你往模具里倒什么材料,出来的就是什么材质的杯子——但形状(也就是逻辑)永远不变。

function box<T>(value: T): T[] {
  return [value]
}

box(123)   // 倒进去 number,出来 number[] 形状的杯子
box('hi')  // 倒进去 string,出来 string[] 形状的杯子

box 这个函数就是模具。T 是"先空着,等会儿再填"的占位符。你不需要为每种材料重新做一个模具——这是泛型存在的唯一理由。

记住这一句话就够了:

看到尖括号 <T>,脑子里直接翻译成"这里的类型先空着,等用的时候再告诉它具体是什么"。


三、判断要不要用泛型,只有一条标准

这是我后来总结出来、也是验证下来最好用的一条判断标准:

如果函数内部的操作只依赖类型里的某个固定特征(比如 .length% 2),就不需要泛型;
如果函数内部根本不关心传进来的是什么类型,只是原样传递、存储或重新排列,那就是泛型该登场的地方。

拿几个例子对照一下,你会发现这条标准非常好用:

场景 要不要泛型 为什么
判断一个数是不是偶数 ❌ 不要 % 2 这个操作只能用在 number 上,类型天生固定
交换两个值的位置 ✅ 要 交换这个动作跟值具体是什么类型完全无关,number、string、对象都一样能交换
取字符串长度 ❌ 不要 .length 只在字符串上有意义,类型写死就行
把任意值包装成单元素数组 ✅ 要 包装这个动作跟值的类型毫无关系

一句话总结:函数摸了类型里的某个具体特征,就别用泛型;函数压根没摸类型,只是搬运,那就该用泛型。


四、语法到底怎么读:先声明,再使用

很多人(包括我)第一次看到这种写法会卡住:

function first<T>(arr: T[]): T {
  return arr[0]
}

诀窍是把这一行拆成两步看:

<T>      ← ① 先"声明":我要用一个叫 T 的类型占位符
T[]、T   ← ② 然后才能"使用"这个占位符,去描述参数类型和返回值类型

T[] 这个写法你其实很熟悉,它跟 number[]string[] 是同一套语法规则,只是把具体类型换成了占位符:

number[]   // 元素是 number 的数组
string[]   // 元素是 string 的数组
T[]        // 元素是 T 的数组,T 具体是什么还不知道,等调用时才确定

调用的时候,T 自动被填上具体类型:

first([1, 2, 3])
// T 被推断成 number
// arr: T[] 这里就变成了 arr: number[]
// 返回类型 T 也变成了 number

可以理解成:变量要先 let T 才能用 T,只不过泛型里"声明"的位置固定写在函数名后面的尖括号里。


五、两种"填模具"的方式

第一种:自动推断——这是大多数场景,TS 会根据你传入的实际值反推类型:

function wrap<T>(value: T): T[] {
  return [value]
}

wrap(123)      // TS 自动推断 T = number,不用你操心

第二种:手动指定——当函数压根没传值,TS 没法从值反推类型的时候,你就得自己在尖括号里写明白:

const arr = wrap<string>('hello')
//              ^^^^^^^^ 手动告诉 TS,T 就是 string

这种场景在日常开发里其实很常见。比如 Vue 的 defineProps

defineProps<{ title: string }>()
// 圆括号是空的,没有值可供推断,只能在尖括号里手动指定类型

再比如 React 的 useState,配合一个空的初始值时:

// 不写泛型,TS 会把 user 类型锁死成 null,之后赋值别的类型会报错
const [user, setUser] = useState(null)

// 手动指定泛型,告诉 TS:虽然现在是 null,但之后可能变成 User
const [user, setUser] = useState<User | null>(null)

六、泛型用在哪几个地方

① 函数(前面已经看过了)

function first<T>(arr: T[]): T {
  return arr[0]
}

② interface / type——做接口请求时特别好用,可以写一个通用的响应结构:

interface ApiResponse<T> {
  data: T
  status: number
}

type User = { id: number; name: string }
type Product = { id: number; price: number }

function fetchData<T>(url: string): Promise<ApiResponse<T>> {
  return fetch(url).then(res => res.json())
}

const userRes = await fetchData<User>('/api/user')           // data 是 User 类型
const productRes = await fetchData<Product>('/api/product')  // data 是 Product 类型

③ React Hook(日常最常碰到的场景)

useState<number>(0)
useState<User | null>(null)

④ class

class Box<T> {
  content: T
  constructor(value: T) {
    this.content = value
  }
}

const numberBox = new Box<number>(123)
const stringBox = new Box<string>('hello')

七、进阶一点:泛型约束 (extends)

默认情况下 T 可以是任何类型,但有时候你需要限制它至少满足某个条件,比如至少要有 .length 属性:

// 没有约束,报错:因为 T 可能是 number,而 number 没有 .length
function printLength<T>(item: T) {
  console.log(item.length)  // ❌
}

// 加上约束:T 必须是"有 length 属性"的类型
function printLength<T extends { length: number }>(item: T) {
  console.log(item.length)  // ✅
}

printLength('hello')     // ✅ 字符串有 length
printLength([1, 2, 3])   // ✅ 数组有 length
printLength(123)         // ❌ number 没有 length,报错

extends 在这里不是面向对象那个"继承"的意思,是在说"T 必须长这个样子,至少要满足这个结构"。


八、顺带提一句:函数参数解构 + 类型标注,也是一个常见的卡点

学泛型的过程中,我还被另一个长得很像但完全无关的写法绊住过:

function RootLayout({ children }: { children: React.ReactNode }) {
  return <div>{children}</div>
}

这一行其实是两件独立的事挤在一起,恰好都用了花括号,容易看串:

{ children }                    ← JS 的解构赋值(拿到参数对象里的 children 属性)
{ children: React.ReactNode }   ← TS 的类型标注(说明这个参数长什么样)

冒号 : 是分界线,左边是解构,右边是类型,两者其实没有关系,只是因为长得像才让人犯迷糊。如果字段多了,建议拆开写:

type RootLayoutProps = {
  children: React.ReactNode
}

function RootLayout({ children }: RootLayoutProps) {
  return <div>{children}</div>
}

可读性立刻好很多。


九、最后,一张速查表收尾

看到这个写法 直接这样理解
<T><User><number> 这里的类型先空着,等用的时候再告诉它具体是什么
{ x }: { x: Type } 冒号左边是解构(JS),右边是类型(TS),两件独立的事
T[] “元素类型为 T 的数组”,跟 number[] 是同一套写法规则
<T extends {...}> T 可以是任何类型,但必须至少满足这个结构

如果你也是看了无数遍语法但脑子里一直没有画面的人,希望"模具"这个比喻能帮你也接上这条线。理解到这一步之后再回头看语法,会发现其实没那么复杂——卡住我们的从来不是语法本身,而是没人帮我们先画出那张画面。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐