#Next.js#TypeScript

TypeScript 泛型与 Utility Types 深入

泛型是 TS 最强也最容易过度使用的特性。本文讲清泛型的正确用法(约束/默认值/推断)、内置 Utility Types(Partial/Required/Pick/Omit/Record/ReturnType/Parameters/Awaited)、什么时候用泛型什么时候不用,以及"类型应该帮你而非困住你"。

本文要点

  • 主题聚焦:Next.js、TypeScript
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年2月9日·更新于 2026年7月23日

TypeScript 泛型与 Utility Types 深入

泛型是 TypeScript 里口碑最两极的特性。用得好的人离不开它——一个函数能安全地适配十种类型,类型推导全程自动,改一处全链路跟着变;用不好的人恨之入骨——签名里堆三四个 <T, U, K extends keyof T>,调用时报错信息像天书,同事接手时直接 any 了事。

这两种结局的差别,不在泛型本身,而在用泛型的人有没有想清楚"我在参数化什么"。

这篇文章不讲类型体操。我讲三件事:泛型的正确用法(约束、默认值、推断)、内置 Utility Types 怎么当日常工具用、以及最关键的——什么时候不要用泛型。类型系统是工具,不是目的。看完这篇,你应该能判断出代码库里哪些泛型是收益、哪些是包袱。


一、泛型的本质:把"类型"当成参数

先把概念立住。泛型就是参数化类型——你把"具体是什么类型"这件事,推迟到使用时再决定,用一个占位符(T、U、K...)来代表"某个类型"。

最经典的例子是 identity 函数:传入什么,返回什么,类型要保持一致。

ts
// 不用泛型:类型信息丢了
function identity(value: any): any {
  return value;
}

const n = identity(42);
//    ^? any —— TS 不知道它是 number,失去了所有类型保护

// 用泛型:类型被"记住"了
function identity<T>(value: T): T {
  return value;
}

const n = identity(42);
//    ^? number
const s = identity('hello');
//    ^? string

<T> 声明了一个类型变量 T。调用 identity(42) 时,TS 推断出 T = number,于是入参和返回值都成了 number。一个函数,适配了所有类型,且每个调用点的类型都是精确的。这就是泛型的价值:写一次,适配多种类型,且不牺牲类型安全。

这个心智模型跟函数参数完全一样——函数参数把"值"参数化(调用时再传具体值),泛型把"类型"参数化(调用时再传具体类型)。理解到这一层,泛型就不再神秘。

泛型可以用在函数、接口、类型别名、类上:

ts
// 接口
interface Box<T> {
  value: T;
}
const numBox: Box<number> = { value: 42 };
const strBox: Box<string> = { value: 'hi' };

// 类型别名
type Pair<A, B> = { first: A; second: B };
const p: Pair<number, string> = { first: 1, second: 'a' };

// 类
class Stack<T> {
  private items: T[] = [];
  push(item: T) { this.items.push(item); }
  pop(): T | undefined { return this.items.pop(); }
}
const nums = new Stack<number>();
nums.push(1); // OK
// nums.push('x'); // 报错

关键认知:泛型不是"让代码更高级",而是"让一段逻辑在不同类型上复用,同时保持类型精确"。如果你的泛型没有带来"复用 + 类型精确"这两点,它大概率是多余的。


二、泛型推断:大多数时候你不用手写类型参数

新手写泛型容易犯一个错——每个调用点都把类型参数显式写出来:

ts
const n = identity<number>(42);   // 多余
const arr = Array.from<string>(['a', 'b']); // 多余

这违背了泛型的初衷。泛型的核心收益之一,就是调用时自动推断,让你不用写类型注解。 TS 会根据传入的实参,反推 T 是什么。上面的 identity(42),TS 看到 42 是 number,自动推断 T = number,根本不用你写 <number>。

什么时候才需要显式写类型参数?三种场景:

ts
// 1. 推断结果不够精确,需要"收窄"
function parseJSON<T>(raw: string): T {
  return JSON.parse(raw) as T;
}
// 不写 <User> 的话,T 推断成 unknown/default,拿不到字段提示
const user = parseJSON<User>('{"id":1}');
user.id; // 有提示

// 2. 推断出的类型太宽(比如字面量被推断成基类型)
function tuple<T extends readonly unknown[]>(...args: T): T {
  return args;
}
// 不显式写,t1 会被推断成 (string | number)[]
const t1 = tuple<string, number>('a', 1);

// 3. 推断不出来——比如入参类型和目标类型没有直接关联
function create<T>(): T { /* ... */ return {} as T; }
const x = create<Foo>(); // 必须显式,否则 T 无法推断

原则:能推断就别写。 显式类型参数是"覆盖推断"的手段,只在推断结果不符合预期时才用。满屏 <T, U, K> 的代码,通常是作者没搞清楚哪些该省。


三、泛型约束(extends):给类型参数画个圈

无约束的 T 是"任意类型",这往往太宽——你想在函数体里访问 T 的某个属性,TS 会拦住你,因为它不知道 T 有没有这个属性。

ts
// 报错:Property 'length' does not exist on type 'T'
function getLength<T>(value: T): number {
  return value.length;
}

这时候用 extends 给 T 加约束。T extends U 的意思是:T 必须是 U 的子类型(满足 U 的形状)。

ts
// T 必须有 length 属性
function getLength<T extends { length: number }>(value: T): number {
  return value.length; // OK
}

getLength('hello');   // OK,string 有 length
getLength([1, 2, 3]); // OK,数组有 length
// getLength(42);     // 报错,number 没有 length

最常用的约束是 T extends keyof U(T 必须是 U 的某个 key)和 T extends string | number(限制为基础类型)。看一个实战例子——类型安全的属性访问器:

ts
function pluck<T, K extends keyof T>(obj: T, keys: K[]): T[K][] {
  return keys.map((k) => obj[k]);
}

const user = { id: 1, name: 'Alice', age: 30 };

pluck(user, ['name', 'age']); // OK,返回 (string | number)[]
// pluck(user, ['email']); // 报错:'email' 不是 user 的 key

K extends keyof T 这一行的价值:你填错 key 名,编译期就挡掉,不用等运行时 undefined。 这正是泛型 + 约束组合的典型收益——把"文档级的约束"变成"编译期的检查"。

一个常被忽略的点:约束不是断言。T extends { length: number } 只保证 T 至少有 length,不保证它只有 length。所以函数内部把 T 当成 { length: number } 来用是安全的,但返回 T 时它仍是精确的 T——这正是约束的精妙之处:约束收紧了"能做什么",但保留了"具体是什么"的精确性。


四、泛型默认值:让类型参数可选

类型参数可以给默认值,语法是 T = SomeType。这在不传类型参数时提供一个回退类型,常见于工具类型和库的 API 设计。

ts
interface PaginatedResponse<T = unknown> {
  data: T[];
  total: number;
  page: number;
}

// 不传 T —— data 是 unknown[]
const res1: PaginatedResponse = { data: [], total: 0, page: 1 };

// 传 T —— data 是 User[]
const res2: PaginatedResponse<User> = { data: [user1, user2], total: 2, page: 1 };

默认值在"大多数调用点类型固定,少数需要定制"的场景下特别有用。比如一个 fetchJSON<T = unknown>(url),默认返回 unknown(强制调用方收窄,避免盲目信任),需要时再传具体类型。

默认值也能结合约束:

ts
function createFetcher<T extends string = 'GET'>(method?: T) {
  // ...
}

实战里我建议:工具类型/库函数优先考虑默认值,业务代码里的泛型函数通常不需要——业务函数的入参类型应该由调用点决定,给默认值反而掩盖了"调用方没传清类型"的问题。


五、内置 Utility Types 速查:日常 80% 的需求

TS 内置了一组工具类型,本质是用条件类型、映射类型、infer 封装好的"类型函数"。你不需要懂它们的实现原理(那是另一篇文章的事),只需要知道什么场景用哪个。这一节是速查手册,建议收藏。

先建一个贯穿全文的示例类型:

ts
interface User {
  id: number;
  name: string;
  email?: string;        // 可选
  readonly createdAt: Date;
}

Partial —— 全部变可选

ts
type UpdateUser = Partial<User>;
// 等价于 { id?: number; name?: string; email?: string; createdAt?: Date }

用途:PATCH 请求体、表单的"部分字段更新"、对象的渐进式构造。

ts
function updateUser(id: number, patch: Partial<User>) {
  // patch 里每个字段都可能没有,但有的字段类型是对的
}
updateUser(1, { name: 'Bob' }); // OK,只改 name

Required —— 全部变必填(Partial 的反操作)

ts
type StrictUser = Required<User>;
// 等价于 { id: number; name: string; email: string; createdAt: Date }

用途:某些流程里要求"所有字段必须齐"(比如写入数据库前的校验),用 Required 把可选项收严。注意它也会去掉 readonly 吗?不会——Required 只去掉 ?,不影响 readonly。

Pick —— 挑选指定字段

ts
type UserSummary = Pick<User, 'id' | 'name'>;
// 等价于 { id: number; name: string }

用途:从大对象里抽出"对外暴露的子集"——API 列表返回摘要、JWT payload 只要 id+name、组件 props 只要父级的一部分。

Omit —— 排除指定字段(Pick 的反操作)

ts
type CreateUserInput = Omit<User, 'id' | 'createdAt'>;
// 等价于 { name: string; email?: string }

用途:这是后端写 CRUD 时用得最多的工具类型。创建对象时 id 由数据库生成、createdAt 由服务端填,前端不该传。Omit 把这两个字段摘掉,剩下的就是用户该提交的输入。

Record —— 构造一个 key→value 映射

ts
type UserRole = 'admin' | 'editor' | 'viewer';
type RoleCount = Record<UserRole, number>;
// 等价于 { admin: number; editor: number; viewer: number }

const counts: RoleCount = { admin: 3, editor: 5, viewer: 12 }; // 三个 key 都必须

用途:枚举到值的映射、状态机、配置表。Record<K, V> 比 { [key: string]: V } 严格得多——前者要求 key 必须是 K 集合,后者接受任意 string,容易漏 key。

ts
// 状态机的状态 → 处理函数
const handlers: Record<Status, (s: State) => State> = {
  idle: handleIdle,
  loading: handleLoading,
  done: handleDone,
};
// 漏掉任何一个状态,编译期就报错

Readonly —— 全部变只读

ts
type FrozenUser = Readonly<User>;
// 所有字段变成 readonly,赋值会报错

用途:函数入参声明为 Readonly<T> 来承诺"我不修改它";Redux/状态管理里强制 state 不可变。readonly 是编译期的,运行时并不冻结对象,别和 Object.freeze 搞混。

ReturnType —— 拿到函数的返回值类型

ts
function getUser(id: string): Promise<User> { /* ... */ }

type R = ReturnType<typeof getUser>;
//    ^? Promise<User>

用途:避免手写两份类型。函数改了返回值,引用处自动跟着变。

Parameters —— 拿到函数的参数类型(元组)

ts
type P = Parameters<typeof getUser>;
//    ^? [id: string]

type FirstParam = P[0]; // string

用途:包装一个函数时,复用它的参数类型。

Awaited —— 解包 Promise(递归)

ts
type R1 = Awaited<Promise<User>>;          // User
type R2 = Awaited<Promise<Promise<number>>>; // number(递归解包)
type R3 = Awaited<User>;                    // User(非 Promise 原样返回)

用途:Awaited<ReturnType<T>> 是 async 函数类型推导的黄金组合,下面实战会展开。

这一组工具类型的关系

记住这几个对偶关系,能帮你快速选型:

工具类型作用对偶
Partial<T>全选 → 全可选Required<T>
Pick<T, K>保留指定字段Omit<T, K>
Readonly<T>加 readonly(无内置反向,需自定义)
ReturnType<T>拿返回值Parameters<T>(拿参数)
Awaited<T>解包 Promise(配合 ReturnType 用)

把这些用熟,日常 80% 的"重塑对象形状"需求都能覆盖,根本不需要自己写复杂类型。


六、实战一:API 响应类型推导,Pick 必填 + Partial 可选

来看真实场景。你有一个完整的 User 实体,但 API 返回的列表页只需要部分字段,其中 name 必填、avatar 可选。怎么用 Utility Types 组合出这个类型,而不是重新手写一份?

ts
interface User {
  id: number;
  name: string;
  email?: string;
  avatar?: string;
  createdAt: Date;
  deletedAt: Date | null;
}

// 列表页响应:只要 id + name + avatar,其中 avatar 可选
type UserListItem = Pick<User, 'id' | 'name' | 'avatar'>;
//    { id: number; name: string; avatar?: string }

Pick 会保留原字段的可选性(avatar 本来就是 ?,挑出来还是 ?)。这是好事——单一数据源,不重复定义。

但有时你需要显式控制可选性。比如一个"编辑用户"表单,所有字段都要填(包括原本可选的 email),但 id 不能改:

ts
// 编辑表单:所有字段必填,但排除 id 和时间戳
type EditUserForm = Omit<Required<User>, 'id' | 'createdAt' | 'deletedAt'>;
//    { name: string; email: string; avatar: string }

Required<User> 先把所有字段转必填,再 Omit 掉不该出现的字段。两步组合,比手写一遍 interface 安全得多——User 加字段时,表单类型不会偷偷过期。

再来看分页响应的封装,这是 Next.js / 全栈项目里最常见的模式:

ts
interface Paginated<T> {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
  hasNext: boolean;
}

// 后端函数
async function fetchUsers(page: number): Promise<Paginated<UserListItem>> {
  const res = await fetch(`/api/users?page=${page}`);
  return res.json();
}

// 前端直接从函数推导类型,零手写
type UsersResponse = Awaited<ReturnType<typeof fetchUsers>>;
//    Paginated<UserListItem>

type SingleUser = UsersResponse['data'][number];
//    UserListItem —— 列表里单个元素的类型

这段代码的精髓:fetchUsers 的返回类型是唯一的真相来源,前端的 UsersResponse、SingleUser 全是从它派生出来的。后端改了字段,前端类型自动更新,永远不会有"接口和类型对不上"的 bug。这种"单源派生"的写法,是 TS 全栈开发的核心红利。


七、实战二:组件 Props 组合,交叉类型 & 的威力

React 组件(以及 Next.js 的 Server Components)的 Props,最忌讳"一个大 interface 塞所有字段"。好的 Props 设计是用小类型组合出来,而组合的主力就是交叉类型 & 和 Utility Types。

ts
// 基础按钮属性,复用原生 button
type BaseButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;

// loading 状态的额外属性
type LoadingProps = {
  loading?: boolean;
};

// 图标的额外属性
type IconProps = {
  icon?: React.ReactNode;
  iconPosition?: 'left' | 'right';
};

// 组合成最终的 Props
type ButtonProps = BaseButtonProps & LoadingProps & IconProps;

function Button({ loading, icon, iconPosition, ...rest }: ButtonProps) {
  // rest 自动拿到所有原生 button 属性(onClick、disabled、type...)
  return <button {...rest}>{/* ... */}</button>;
}

& 把多个类型合并成一个——最终的对象必须同时满足所有类型。这比把字段全写进一个 interface 有三个好处:可复用(LoadingProps 别的组件也能用)、可读(Props 的"组成"一目了然)、可演进(加一个能力就 & 一个类型)。

再看一个 Next.js 里常见的——页面 Props 是"路由参数 + 数据"的组合:

ts
import { notFound } from 'next/navigation';

// 路由参数:动态路由 [slug]
type Params = { slug: string };

// 搜索参数:?ref=xxx
type SearchParams = { [key: string]: string | string[] | undefined };

// 页面 Props = 路由参数 + 搜索参数 + 数据
type PageProps = {
  params: Promise<Params>;
  searchParams: Promise<SearchParams>;
};

export default async function PostPage({ params, searchParams }: PageProps) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();
  // ...
}

这里没有用泛型,因为页面 Props 是固定的——固定的形状就该用确定类型,不该为了"通用"硬上泛型。

什么时候 Props 该用泛型?当组件的某个属性类型依赖于另一个属性时。最典型的是泛型选择器组件:

ts
interface SelectProps<T extends string> {
  options: readonly T[];
  value: T;
  onChange: (value: T) => void;
  placeholder?: string;
}

function Select<T extends string>(props: SelectProps<T>) {
  // ...
}

const status = Select<'idle' | 'done' | 'error'>({
  options: ['idle', 'done', 'error'],
  value: 'idle',
  onChange: (v) => console.log(v), // v 的类型精确推导
});

T extends string 让 options、value、onChange 三处的 T 强相关——你传 ('a' | 'b')[] 的 options,onChange 收到的就只能是 'a' | 'b',不可能传错。这是泛型在组件里的高价值场景:多个属性之间存在类型依赖。

判断标准很简单:属性之间有"协同变化"的依赖,用泛型;否则用交叉类型组合就够。


八、什么时候不该用泛型

讲了这么多泛型的好处,必须反过来讲——大多数过度复杂的泛型,都是滥用。这是这篇文章我想强调最多的部分,因为新手和中级开发者最容易掉进"看到泛型就想用"的陷阱。

信号一:能用 union 解决的,别上泛型

ts
// ❌ 过度设计:T 只在两处出现,且根本没做有意义的约束
function format<T extends string | number>(value: T): string {
  return String(value);
}

// ✅ union 足够,简单直接
function format(value: string | number): string {
  return String(value);
}

判断:如果类型参数只在"入参和出参同一类型"这种简单关联中出现,且没有跨属性的依赖,union 更清晰。泛型的成本(认知负担、报错信息复杂度)只有在收益足够大时才值得付。

信号二:泛型参数没被"真正使用"

ts
// ❌ T 声明了但函数体里根本没用到它的类型信息
function log<T>(message: string): void {
  console.log(message);
}

// ✅ 删掉
function log(message: string): void {
  console.log(message);
}

一个常见变体:为了"看起来通用",给每个函数都加 <T>,但 T 既没有约束、也没有在签名里被有意义地关联。这种泛型是装饰性的,删掉对类型安全没有任何损失。

信号三:泛型参数超过 3 个

ts
// ❌ 这种签名没人愿意读
function transform<T, U, K extends keyof T, V, W>(...): ??? { ... }

类型参数越多,可读性越差、推断越容易出错、报错越难懂。经验法则:泛型参数超过 3 个,基本说明你在用类型系统写"程序",而非"约束"。这种时候拆成多个函数、或者退一步重新设计 API,通常比硬撑泛型更好。

信号四:调用点全是显式类型参数

ts
// 每个调用点都得写 <User>、<Post>... 说明推断根本没帮你
const a = fetch<User>('/users/1');
const b = fetch<Post>('/posts/1');

如果你发现每个调用点都得手写类型参数,往往说明泛型设计有问题——要么约束没设好、要么 API 形状不对。好的泛型 API,调用点应该是 fetchResource('/users/1') 这样干净、类型自动推导出来的。

信号五:为了泛型而泛型,把简单问题复杂化

ts
// ❌ 一个只会处理 User 的函数,硬要泛型化
function getUserName<T extends User>(user: T): string {
  return user.name;
}

// ✅ 这函数本来就只服务 User,直接写
function getUserName(user: User): string {
  return user.name;
}

"将来可能复用"不构成现在用泛型的理由。 YAGNI(You Aren't Gonna Need It)原则在类型设计里同样适用。等真有第二个类型要用时,再改成泛型,成本远小于现在就背一个过度抽象的签名。


九、类型应该帮你,而不是困住你

前面讲的是技术判断,这一节讲理念。我见过太多团队把 TypeScript 用反了——本意是"用类型挡 bug",结果变成了"类型系统逼着写一堆 as any、@ts-ignore、as unknown as X 才能跑通"。这种情况下,类型不是在帮你,而是在困住你。

几个识别信号:

信号一:业务代码里高频出现 as 断言。 类型断言是告诉编译器"闭嘴,我知道我在干嘛"。偶尔用是合理的(和外部库对接、JSON 解析),但如果每写十行就有一个 as,说明类型设计和你实际的业务对不上——要么类型太严,要么类型错了。正确的反应是改类型,而不是堆 as。

信号二:一个工具类型超过 5 行,或者你自己一个月后看不懂。 这是类型体操的红线。类型体操(用递归条件类型在类型层面实现各种算法)是很好的学习练习,但不要进生产代码。原因很实际:报错信息是几百字符的天书、同事改一个字段就推导崩掉、编译速度肉眼可见变慢。我自己有一条死规矩——工具类型写完,如果能用一句话向同事解释它干了什么,就留下;解释不清,就拆或删。

信号三:为了过类型检查,把 any 偷偷藏在暗处。 as any、// @ts-ignore、把对象先转 unknown 再转目标类型——这些都是"类型债务"。短期看是省事了,长期看是把 bug 推迟到运行时,且排查时毫无线索。unknown 比 any 好,因为 unknown 强制你收窄;any 是彻底放弃。 如果一段代码非 any 不可,至少加注释说明为什么、什么时候能还掉。

信号四:类型推导慢到影响开发体验。 tsserver 卡顿、IDE 补全延迟、tsc 编译要几十秒——这些往往是过度复杂的类型(尤其是深层递归条件类型)造成的。类型系统不是免费的,复杂的类型推导是有计算成本的。性能和可读性一样,是类型设计的约束条件。

那么"好的类型"长什么样?我总结三条标准:

  1. 它能挡住真实的 bug——填错字段名、传错参数顺序、漏处理 null,这些在编译期就被拦下。
  2. 它提升了 DX(开发体验)——写代码时 IDE 能提示有哪些字段、有哪些可选值,不用翻文档。
  3. 它的维护成本低于它挡住的 bug 成本——如果维护一个类型比手写测试还累,这个类型就是负资产。

Utility Types 之所以是好工具,是因为它们刚好命中这个甜区:用最少的语法(Partial<T>、Pick<T, K>、Awaited<ReturnType<T>>),拿到最大的类型安全和补全收益,且任何 TS 开发者都能一眼看懂。 这才是类型系统该有的样子——轻量、精确、帮你而非困住你。


小结

  1. 泛型的本质是参数化类型——把"具体是什么类型"推迟到调用时,用 T 占位。核心收益是"写一次,适配多种类型,且每个调用点类型精确"。能推断就别手写类型参数。
  2. 约束(extends)给类型参数画圈——T extends keyof U 把"文档级约束"变成"编译期检查",且不丢失 T 的精确性;默认值(T = string)让类型参数可选,适合工具类型和库 API。
  3. 内置 Utility Types 是日常主力——Partial/Required、Pick/Omit、Record、Readonly、ReturnType/Parameters、Awaited。记住几组对偶关系,80% 的"重塑对象形状"需求不用自己写类型。
  4. 实战两个黄金模式:API 响应类型用 Awaited<ReturnType<T>> + Pick/Omit 做单源派生(后端改字段前端自动同步);组件 Props 用交叉类型 & 组合小类型,存在跨属性依赖时才上泛型。
  5. 过度泛型是负债——union 能解决就别上泛型、泛型参数超过 3 个基本是设计问题、调用点全是显式类型参数说明推断没帮你、类型体操不进生产代码。

金句:好的类型系统不是逼你写更多 <T>,而是用最少的类型语法,把最多的约束编译期化——类型是工具不是目的,当一段类型代码让你觉得"聪明"却让同事觉得"难懂"时,它就已经在困住你了。

Related

相关推荐