《TypeScript编程入门》8.2 泛型默认值与类型推断

本节讲清泛型默认值的语法、求值顺序,以及默认值必须满足约束这条硬规则。随后系统梳理类型推断的四个来源与优先级,解释为什么 TypeScript 至今不支持部分推断、如何用柯里化绕过,并介绍 TS 5.0 的 const 类型参数如何保住字面量精度。最后用可配置的请求封装串起全部知识点,给出常见推断失败场景与修法。

本节目标:读完这一节,你能给类型参数写出带约束的默认值 <T extends C = D>;能说清默认值、实参推断、显式实参三者的优先级;能判断一段泛型调用为什么会「推断成我不想要的那个类型」并修好它;能理解「TypeScript 不支持部分推断」的含义与柯里化绕过手法;并会用 const 类型参数保住字面量精度。

8.2 泛型默认值与类型推断

上一节我们学会了用 extends 收窄类型参数。但约束只回答「允许什么」,当调用者既没写实参、实参里也推不出信息时,编译器就需要一个兜底答案——这就是默认值。

同时,泛型最舒服的体验来自「什么都不用写就能推对」。可现实里推断经常给出意料之外的结果。这一节把默认值与推断放在一起讲,是因为它们本来就是同一枚硬币的两面:默认值是推断失败时的兜底,推断是默认值生效前的尝试。

默认值的语法

默认值写在类型参数名之后,用 = 引入:

interface Box<T = string> {
  value: T;
}

const a: Box = { value: "hello" };        // T 用默认值,等价于 Box<string>
const b: Box<number> = { value: 42 };     // 显式指定,覆盖默认值

函数上同样适用:

function createArray<T = string>(length: number, fill: T): T[] {
  return Array.from({ length }, () => fill);
}

需要注意:函数泛型的默认值只在无法推断时才生效。上面这个例子里,fill 参数就能推断出 T,所以默认值 string 几乎永远不会被用到:

createArray(3, "x");  // T 由 fill 推断为 string,默认值没用上
createArray(3, 0);    // T 推断为 number,默认值也没用上

真正需要默认值的场合是「类型参数在参数列表里没有出现」:

interface ApiResponse<T = unknown> {
  code: number;
  data: T;
}

interface User { id: number; name: string; }
async function request<T = unknown>(url: string): Promise<ApiResponse<T>> {
  const response = await fetch(url);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const data: unknown = await response.json();
  return { code: response.status, data: data as T };
}

const r1 = request("/users");       // Promise<ApiResponse<unknown>>
const r2 = request<User>("/users"); // Promise<ApiResponse<User>>

示例需在提供 /users 接口的浏览器页面运行。data as T 不验证字段,显式传 <User> 只是调用方的承诺;生产代码应增加运行时校验。T 只出现在返回类型里,编译器无法从实参推断,默认值 unknown 就成了唯一出路。这是默认值最主要的应用场景:把「无法推断的返回类型」兜住,避免退化成 any。

默认值必须满足约束

默认值不是随便写的,它必须满足同一位置声明的约束,否则报错:

function pick<T extends { id: number } = { name: string }>(x: T) { /* ... */ }
// ❌ Type '{ name: string; }' does not satisfy the constraint '{ id: number; }'.

修正方式有两种:让默认值满足约束,或者把约束放宽。

// 方案一:默认值满足约束
function pick<T extends { id: number } = { id: number }>(x: T) { /* ... */ }

// 方案二:约束放宽到 unknown,但函数体就失去保障了(不推荐)

求值顺序也有一条硬规则:默认值只能引用它前面已经声明的类型参数。

// ✅ 正确:T 在前,U 可以引用 T
interface Pair<T, U = T> {
  first: T;
  second: U;
}

const p: Pair<number> = { first: 1, second: 2 }; // U 取默认值 = T = number

// ❌ 错误:U 在前,T 还没声明
interface Bad<T = U, U> { /* ... */ }
// Type parameter 'T' has a default but is followed by a required type parameter.

最后那条报错还有一层含义:有默认值的类型参数不能排在无默认值的类型参数前面。这与函数参数「可选参数必须在必选参数之后」是同一条规则,目的是让省略实参时不会产生歧义。

推断的四个来源

现在进入核心问题:当调用者没写实参时,编译器从哪里找 T?答案是有四个来源,按优先级从高到低:

优先级来源示例
1(最高)显式传入的类型实参getProp<User, "id">(user, "id")
2从函数实参推断identity("hi") → T = string
3从返回类型的上下文推断const x: number = fn() 中的 fn<T>
4(最低)类型参数的默认值<T = unknown>

前三条只要有一条能确定 T,后面的就都不会参与。这个顺序解释了很多「为什么推断成了这个类型」的困惑。

来源 2 是最常见的。 它的规则叫「候选集合 + 最佳公共类型」:

function firstOf<T>(...items: T[]): T | undefined {
  return items[0];
}

firstOf(1, 2, 3);          // T = number
firstOf("a", "b");         // T = string
firstOf(1, "a", true);     // T = string | number | boolean(联合)

多个实参给出不同候选时,编译器取它们的联合类型。这通常是对的,但有时会带来意外:

function pair<T>(a: T, b: T): [T, T] {
  return [a, b];
}

pair({ id: 1 }, { name: "Ada" });
// T = { id: number } | { name: string }
// 返回 [T, T],于是访问 result[0].name 会报错

很多人期待 T 变成 { id: number; name: string },但推断规则给的是联合。遇到这种情况要么显式指定 pair<{ id: number; name: string }>(...),要么把两个参数改成不同的类型参数。

来源 3 常被忽略。 当返回类型出现在一个「已知类型的位置」时,编译器会反推:

function empty<T>(): T[] {
  return [];
}

const nums: number[] = empty(); // T 由上下文推断为 number
const strs = empty();           // ⚠️ 没有上下文,T 退化为 unknown

empty() 单独使用时返回 unknown[]——比 any[] 安全,但基本没法用。这说明泛型函数的返回值一定要有上下文或者显式实参,否则默认值/兜底类型会立刻暴露出来。

为什么不支持部分推断

这是初学者最常撞上的一堵墙。看这个函数:

function makeMap<K, V>(key: K, value: V): Map<K, V> {
  return new Map([[key, value]]);
}

K 能从 key 推出,V 能从 value 推出,一切正常。但下面这种写法就尴尬了:

interface Store<S, A> { getState(): S; dispatch(action: A): void; }
function createStore<TState, TAction>(initial: TState): Store<TState, TAction> {
  return { getState: () => initial, dispatch: (action) => console.log(action) };
}
const store = createStore({ count: 0 }); // 合法:TAction 回退为 unknown
// createStore<{ count: number }>({ count: 0 }); // TS2558:需要两个类型实参

TState 能推断,但 TAction 推不出来。此时你不能只写一半:createStore<{ count: number }>(...) 会报「类型实参数量不匹配」。没有默认值的类型参数在显式指定时必须给齐;有默认值的后续参数可以省略,但会使用默认值,并不会继续从实参推断。这就是这里说的「不支持部分推断」。(这个限制在官方仓库里是长期开放的建议,至今未实现。)

绕过它的标准手法叫柯里化——把类型参数拆到两层函数上,让每层只推断自己需要的那些:

interface Store<S, A> {
  getState(): S;
  dispatch(action: A): void;
}

function createStore<TAction>() {
  return function <TState>(initial: TState): Store<TState, TAction> {
    return { getState: () => initial, dispatch: (action) => console.log(action) };
  };
}
const makeCounter = createStore<{ type: "inc" }>(); // 外层显式指定动作
const store = makeCounter({ count: 0 }); // 内层从参数推断状态
console.log(store.getState().count); // 0
store.dispatch({ type: "inc" }); // { type: 'inc' }

代价是多了一层调用。工程里的取舍是:如果某个类型参数推不出来,优先给它加默认值;只有当默认值不合适时,才上柯里化。

const 类型参数:保住字面量精度

TypeScript 5.0 引入了 const 修饰的类型参数,解决的是一个老问题:推断会把字面量「加宽」。

function tuple<T>(...items: T[]): T[] {
  return items;
}

const t1 = tuple("a", "b");        // T = string,丢了字面量
const t2 = tuple<"a" | "b">("a", "b"); // 手动指定,太啰嗦

加 const 之后:

function tuple2<const T>(...items: T[]): T[] {
  return items;
}

const t3 = tuple2("a", "b");       // T = readonly ["a", "b"](元组 + 字面量)

const T 的效果等价于「对每个实参都做了 as const」,让编译器倾向于保留最窄的字面量类型。它在配置对象、路由表、事件名列表这类场景极其有用:

declare function defineRoutes<const T extends readonly string[]>(routes: T): T;

const routes = defineRoutes(["/", "/about", "/posts"]);
// 类型是 readonly ["/", "/about", "/posts"],元素可以逐个被联合类型消费
type Route = (typeof routes)[number]; // "/" | "/about" | "/posts"

typeof routes[number] 这个技巧把数组元素变成联合类型,是「用值生成类型」的起点,9.1 keyof·typeof 与索引访问类型 会继续展开。

实战:一个可配置的请求封装

把本节的知识点串起来,写一个真实项目里常见的封装:

interface RequestOptions<TBody = unknown, TQuery = Record<string, string>> {
  method?: "GET" | "POST" | "PUT" | "DELETE";
  body?: TBody;
  query?: TQuery;
}

async function request<
  TData,
  TBody = never,
  TQuery = Record<string, string>,
>(
  url: string,
  options: RequestOptions<TBody, TQuery> = {},
): Promise<TData> {
  const qs = options.query
    ? "?" + new URLSearchParams(options.query as Record<string, string>).toString()
    : "";
  const res = await fetch(url + qs, {
    method: options.method ?? "GET",
    headers: options.body === undefined ? undefined : { "Content-Type": "application/json" },
    body: options.body === undefined ? undefined : JSON.stringify(options.body),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return (await res.json()) as TData; // 仍需在真实边界校验响应字段
}

逐个看这里的设计取舍:

类型参数来源为什么这样写
TData显式实参返回值靠 res.json() 无法推断,必须由调用者声明
TBody完全推断的调用或显式实参;省略时取默认值显式指定 TData 后,省略 TBody 会使用 never,不再从 body 推断
TQuery默认值绝大多数请求用 Record<string, string> 就够了

TBody = never 是一个小技巧:没有普通值能赋给 never;body?: never 允许省略,在本节配置下读取时类型为 undefined,因此不能传入普通请求体。调用者想传 body 就必须显式给出类型,逼他写清楚:

interface User { id: number; name: string }

async function demo(): Promise<void> {
// ✅ TData 显式给,TBody 用默认值 never
const user = await request<User>("/api/me");

// ✅ 两个类型实参都显式给出,TBody 并非推断结果
const created = await request<User, { name: string }>("/api/users", {
  method: "POST",
  body: { name: "Ada" },
});

// ❌ TBody 是 never,不能传 body
// await request<User>("/api/users", { method: "POST", body: { name: "Ada" } });
// TS2322:对象不能赋给 undefined(未启用 exactOptionalPropertyTypes)
console.log(user.name, created.name);
}
// 在提供上述接口的浏览器页面中执行:void demo();

两个 request 示例应分开运行,后者需要接续本小节前面的 RequestOptions 与 request 定义。泛型并不验证响应内容,实际 API 仍需运行时校验。最后那条报错表达了意图:要传 body 就必须声明 body 的类型。这比让 body 变成 any 安全得多。

常见推断失败场景

现象原因修法
T 推断成 unknown类型参数只出现在返回类型,无上下文显式传实参,或给默认值
T 推断成 string 而非 "a"字面量被加宽用 const T,或 as const
多个参数推断出联合类型候选集合取并集拆成多个类型参数,或显式指定
回调参数变成 any泛型无法从上下文推断回调给回调参数补注解
只写了一半类型实参报错不支持部分推断补全,或改柯里化
空数组推断成 never[]没有元素可作候选显式标注元素类型

其中「回调参数变 any」值得单独说一句:

function map<T, U>(list: T[], fn: (item: T) => U): U[] {
  return list.map(fn);
}

map([1, 2, 3], (item) => item.toString()); // ✅ item 推断为 number

这里 T 先从 list 推断出来,再回填给回调的参数——这个机制叫「上下文推断的延迟求值」。但如果顺序反了,比如 fn 出现在 list 之前,推断就会失败:

function mapBad<T, U>(fn: (item: T) => U, list: T[]): U[] {
  return list.map(fn);
}

mapBad((item) => item.toString(), [1, 2, 3]);
// ❌ Parameter 'item' implicitly has an 'any' type.

实用建议:把「用于推断的类型参数」对应的参数放在前面,让编译器先拿到候选,再回填给回调。这类「参数顺序影响推断结果」的坑,是阅读第三方库签名时最需要留意的地方。

想更系统地了解类型推断的完整规则,可以延伸阅读站内的 TypeScript 类型级编程 。

小结

这一节把「默认值」和「推断」这两件事讲透了:

  • 默认值写法是 <T = D>,且 D 必须满足同一位置的约束;默认值只能引用它前面声明的类型参数,有默认值的参数不能排在必选参数之前。
  • 推断有四个来源,优先级是:显式实参 > 函数实参 > 返回上下文 > 默认值。
  • 多个实参给出不同候选时取联合类型,这常常不是你想要的结果,需要拆类型参数或显式指定。
  • TypeScript 不支持部分推断,绕过手法是柯里化分层,但优先考虑给默认值。
  • const T(TS 5.0)能保住字面量精度,是配置表、路由表场景的利器。
  • 参数顺序影响推断:把用于推断的参数放在前面,让回调参数能被回填。

现在我们已经掌握了泛型的两大支柱:约束与推断。但目前为止所有例子都停在函数上。下一节 8.3 泛型在接口·类·函数中的协同 会把泛型扩展到接口与类,并讲清三者混用时的协作方式与那些「静态成员不能用类类型参数」之类的硬性限制。

阅读导航:上一节:8.1 泛型约束(extends) · 下一节:8.3 泛型在接口·类·函数中的协同 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes