本节目标:读完这一节,你能给类型参数写出带约束的默认值
<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 泛型在接口·类·函数中的协同 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。