#Next.js#TypeScript

TypeScript 高级类型实战

会写 interface 不算懂 TS。本文讲清真正提升类型表达力的进阶技巧:条件类型、映射类型、infer 推导、模板字面量类型、类型体操的边界,以及实战场景(API 响应类型推导、类型安全路由、props 自动补全),让 TS 从"写起来烦"变成"帮你挡 bug"。

本文要点

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

很多人对 TypeScript 的印象停留在:给变量标个 string、给函数写个 interface、把 any 改成 unknown。这些是基本功,但 TS 真正的价值不在“标类型”,而在“让类型帮你推导、帮你挡 bug、帮你补全”。会写 interface 只代表你能描述一个形状,而条件类型、映射类型、infer、模板字面量类型这些东西,决定了你能不能把约束精确地表达出来。

这篇文章不讲花活,讲实战:每一项能力对应一个真实场景,并且讲清它什么时候不该用。

一、高级类型到底解决什么问题

写 TS 最常见的抱怨是“太烦了”。但绝大多数“烦”不是 TS 的问题,而是类型设计的问题——你用了重复、宽泛、需要手写的类型,自然维护起来痛苦。

好的高级类型有三个具体收益:

  1. 精确表达约束:比如“这个字段只能是 'GET' | 'POST' | 'PUT' | 'DELETE'”,而不是 string。前者能在编译期挡掉 gett,后者只能靠运行时报错。
  2. 自动挡 bug:类型之间的依赖关系被编译器跟踪,改了一处,另一处报错。
  3. 类型自动补全:写组件 props、路由参数、API 返回值的时候,IDE 能给你提示,而不是让你去翻文档。

这三点合起来,才是 TS 相对 JS 的核心优势。下面讲怎么真正拿到这些收益。

二、条件类型与分布式条件类型

条件类型的语法是 T extends U ? X : Y,看起来像三元表达式,但它在类型层面是一个判断分支:

ts
type IsString<T> = T extends string ? true : false;

type A = IsString<'hello'>; // true
type B = IsString<42>;      // false

它真正的威力在于分布式条件类型:当 T 是一个联合类型(比如 'a' | 'b' | 'c')时,条件会自动分发到每一个成员上,再把结果合并。

ts
type ToArray<T> = T extends unknown ? T[] : never;

type R = ToArray<'a' | 'b'>; // 'a'[] | 'b'[]

注意,结果是 'a'[] | 'b'[],不是 ('a' | 'b')[]。这两个含义不同:前者是“要么全是 a 的数组,要么全是 b 的数组”,后者是“a 和 b 混着的数组”。

如果你确实想要后者,用方括号把分发关掉:

ts
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;

type R2 = ToArrayNonDist<'a' | 'b'>; // ('a' | 'b')[]

实战里有一个经典应用——从联合类型里过滤掉某些成员:

ts
type Exclude<T, U> = T extends U ? never : T;

type T = Exclude<'a' | 'b' | 'c', 'a'>; // 'b' | 'c'

这正是 TS 内置 Exclude<T, U> 的实现原理。同理 Extract<T, U> 是反过来,只保留匹配的。理解了分发机制,你就知道为什么 Exclude<boolean, false> 会变成 true——因为 boolean 本质是 true | false。

三、映射类型:keyof + 修饰符

Partial、Required、Pick、Omit、Record 这些内置工具类型,本质都是映射类型。映射类型的语法是遍历一个 key 集合,对每个 key 重新定义 value 的类型。

ts
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

keyof T 拿到 T 所有 key 的联合类型,[K in ...] 遍历它们,T[K] 是索引访问。加 ? 让每个字段可选。这就是 Partial 的全部原理。

修饰符除了 ?,还有 +/- 前缀。-? 表示去掉可选,这就是 Required:

ts
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

Pick 用条件类型 + 映射类型组合实现:

ts
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};

type MyRecord<K extends string | number | symbol, V> = {
  [P in K]: V;
};

Omit 没有直接对应的映射语法,通常用 Pick + Exclude 组合:

ts
type MyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

为什么讲这些原理?因为一旦你理解了映射类型的机制,你就能写出业务里的定制工具类型。比如把一个对象所有 value 类型统一变成 Promise<T>:

ts
type Promisified<T> = {
  [K in keyof T]: Promise<T[K]>;
};

这种表达力,JS 永远给不了你。

四、infer:从已有类型里“抠”出片段

infer 是条件类型里的一个声明:在 extends 子句的位置,声明一个类型变量,让 TS 帮你推导它。本质是“模式匹配 + 类型捕获”。

ts
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

读法:如果 T 是一个函数,那么推导出它的返回值类型 R,并返回 R。这就是内置 ReturnType 的实现。

类似地:

ts
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
type Awaited<T> = T extends Promise<infer U> ? U : T;

Awaited 比 ReturnType 更巧妙:它会递归解包嵌套的 Promise,所以 Awaited<Promise<Promise<number>>> 是 number。TS 4.5 之后的内置 Awaited 就是干这个的。

infer 也能用来抠数组、字符串、对象:

ts
type First<T extends any[]> = T extends [infer F, ...any[]] ? F : never;
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
type PropType<T, K extends keyof T> = T[K]; // 不用 infer,直接索引

实战里一个高频场景:从 Promise<API 响应> 直接抽出响应类型,避免手动维护两份。

ts
async function getUser(id: string): Promise<{ id: string; name: string; email: string }> {
  // ...
  return { id, name, email };
}

type User = Awaited<ReturnType<typeof getUser>>;
//   ^? { id: string; name: string; email: string }

改了函数签名,类型自动跟着变。这才是类型系统真正“帮你”的地方。

五、模板字面量类型:字符串也能被类型约束

TS 4.1 引入了模板字面量类型,允许把字符串拼接放到类型层面:

ts
type EventName = `on${Capitalize<'click' | 'hover'>}`;
// 'onClick' | 'onHover'

type Route = `/users/${string}/posts/${string}`;
// 匹配 /users/123/posts/456,不匹配 /foo

这在事件系统、路由、国际化 key 里非常有用。比如定义一个严格类型的事件总线:

ts
type Events = {
  click: { x: number; y: number };
  scroll: { top: number };
};

type EventKey = keyof Events;

function emit<K extends EventKey>(event: K, payload: Events[K]) {
  // ...
}

emit('click', { x: 1, y: 2 }); // OK
emit('click', { top: 1 });     // 报错,payload 类型不对
emit('tap', {});               // 报错,没有 tap 事件

配合 Capitalize、Uppercase、Lowercase、Uncapitalize 这几个内置工具,可以做出非常严密的命名约束。Next.js 的动态路由、tRPC 的过程名、数据库 schema 到 TS 类型的映射,都大量依赖这个能力。

六、实战场景:让类型从“写起来烦”变成“自动补全”

讲一个组合实战——从 zod schema 自动推导 API 响应类型:

ts
import { z } from 'zod';

export const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'member']),
});

export type User = z.infer<typeof UserSchema>;
// 等价于 { id: string; name: string; email: string; role: 'admin' | 'member' }

后端改了 schema,前端类型自动更新,不用手动同步两份 interface。

再比如组件 props 自动补全。一个 Button 组件,你想让它接受 variant 字段,但每种 variant 有不同的额外属性:

ts
type ButtonProps =
  | { variant: 'primary'; onClick: () => void }
  | { variant: 'link'; href: string };

function Button(props: ButtonProps) {
  // ...
}

// 写 <Button variant="primary" /> 时,IDE 会提示要 onClick
// 写 <Button variant="link" /> 时,IDE 会提示要 href
// variant='link' 时填 onClick 会报类型错

这就是判别联合(discriminated union),TS 会根据 variant 的字面量值,自动收窄剩下的字段。这种“类型随字段值变化”的体验,是 JS 永远给不了的。

类型安全路由也是同理。Next.js 里你可以这样约束一个动态路由的参数:

ts
type RouteParams = `/users/${string}`;
function navigate(to: RouteParams) { /* ... */ }

navigate('/users/123'); // OK
navigate('/posts/123'); // 报错

这些例子共同说明一件事:好的类型不是负担,是把“文档”和“约束”编译期化。

七、类型体操的边界:可读性永远大于巧妙

最后讲一个反例。TS 社区有一种叫“类型体操(type gymnastics)”的玩法,用条件类型、递归、infer 在类型层面实现各种算法(比如斐波那契、JSON Parser、塔防游戏)。

这些对学习有帮助,但不要带进生产代码。原因很简单:

  1. 类型报错信息会变成天书。一个嵌套五层的条件类型出错,报错信息是几百个字符的串,谁都看不懂。
  2. 难以修改。同事改一个字段,类型推导挂了,他不知道怎么修。
  3. 编译变慢。复杂的递归类型会显著拖慢编译。

我有一条原则:如果一个工具类型超过 5 行,或者你自己写完一个月后回头看需要重新理解,那就拆开或者别用。

实用工具类型(自己的业务里的 Pick、Omit、Awaited、z.infer)是收益;把类型当编程语言去写算法,基本是负债。

判断标准很简单——类型应该让代码更易读、更易改,而不是用来证明作者聪明。

小结

  • 条件类型 + 分布式机制:是 Exclude、Extract 的实现原理,用来过滤和分支。
  • 映射类型 + keyof:是 Partial、Pick、Omit、Record 的底层,让你能批量重塑对象形状。
  • infer:从函数、数组、Promise 里抽出类型,避免手写两份。
  • 模板字面量类型:让字符串字面量(路由、事件名)也能被严格约束。
  • 判别联合 + schema 推导:是组件 props、API 响应、路由参数类型安全的实战主力。

金句:好的类型系统不是逼你写更多代码,而是让编译器替你记住更多约束——你写一次,它在背后帮你挡一千次 bug。

Related

相关推荐