文章

泛型约束进阶与函数重载设计

面试向讲清 TypeScript 泛型约束与函数重载:extends 为什么是"赋能"而不是"限制"、keyof 联动约束、 多重约束与泛型默认值、重载签名与实现签名的可见性规则、自上而下的重载解析顺序, 以及"什么时候该用重载、什么时候该用联合类型"的取舍标准。

泛型约束进阶与函数重载设计

一句话概括

泛型约束和函数重载,说白了是同一件事的两个面:在不牺牲类型精度的前提下,让 API 更好用。

  • 泛型约束:给类型参数立规矩。有了规矩,你在函数体内才”敢”去用这个类型上的属性。
  • 函数重载:给同一个函数写多份”调用说明书”。不同参数组合进来,调用方拿到不同的精确返回类型。

面试官问这两个,很少让你背语法,通常是丢一段”类型写崩了”的代码让你改:obj[key] 报错、getProperty 返回 any、重载顺序写反导致某个分支永远走不到。能一眼看出毛病在哪、说出原理,这题就过了。

核心知识点

1. extends 约束:不是”限制”,是”赋能”

这是最容易被讲反的一点。约束表面上是”限制 T 能是什么”,实际上它的第一作用是让你在函数体里能安全地用属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ T 可能是任何类型,TS 不敢让你碰 .length
function logLengthBad<T>(item: T): T {
  console.log(item.length); // 编译错误:Property 'length' does not exist on type 'T'
  return item;
}

// ✅ 加约束后,T 保证有 length,函数体内就能安全访问
function logLength<T extends { length: number }>(item: T): T {
  console.log(item.length);
  return item;
}

logLength('hello');   // T = string
logLength([1, 2, 3]); // T = number[]
logLength(42);        // ❌ number 不满足约束

面试答法:约束 = 给类型参数一个下界。没有下界,你在泛型函数里对这个值几乎什么都干不了。

2. 用一个类型参数约束另一个:keyof 联动

这是面试最爱考的一条,也是”约束”真正的威力所在——让两个参数之间产生关联。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ 键名写错了没人告诉你,返回值还丢成了 any
function getPropertyBad(obj: any, key: string): any {
  return obj[key];
}
getPropertyBad(user, 'nmae'); // 拼写错了,编译照样过,运行时 undefined

// ✅ K extends keyof T:key 只能是 obj 真实存在的键,返回值精确到 T[K]
function getProperty<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: 'Alice', role: 'admin' as const };
getProperty(user, 'name'); // 类型 string
getProperty(user, 'role'); // 类型 'admin'(字面量都保留住了)
getProperty(user, 'age');  // ❌ 'age' 不在 'id' | 'name' | 'role' 里

一句话记:K extends keyof T 保证键名合法,T[K] 保证取值精确。这俩是成对出现的。

3. 多重约束、泛型默认值、NoInfer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type HasId = { id: string };
type HasTime = { createdAt: Date };

// 多重约束:用交叉类型 & 拼起来
function logEntity<T extends HasId & HasTime>(e: T): T {
  console.log(e.id, e.createdAt.toISOString());
  return e;
}

// 泛型默认值:调用方不传就用默认;带默认值的参数必须排在必填参数后面
interface ApiResult<T = unknown, E extends Error = Error> {
  data: T;
  error: E | null;
}

// NoInfer<T>(TS 5.4+):标记这个位置"不参与类型推断",防止被参数反向推歪
declare function pick<T>(value: T, validator: (v: NoInfer<T>) => boolean): void;

pick('hello', (v) => v.toUpperCase() === 'HELLO'); // ✅ T 只从 'hello' 推断为 string
pick('hello', (v: number) => v > 0);               // ❌ 参数类型与已推断的 string 冲突

NoInfer 不用背,但面试官提到”推断被污染”时,你能说出这个名字就很加分。

4. 约束 vs 条件类型:一个管”入口”,一个管”出口”

这俩长得像(都有 extends),但干的活完全不同,面试很容易被问混:

 写在哪干什么例子
泛型约束 T extends U类型参数声明处限定 T 的下界(什么能传进来)<T extends { id: string }>
条件类型 T extends U ? A : B类型别名 / 返回类型根据 T 分叉(算出什么出去)type IdOf<T> = T extends { id: infer I } ? I : never

记住:约束是入参的门槛,条件类型是出参的分支。

5. 函数重载:两个签名 + 一个实现

重载的结构就三样东西:

  1. 重载签名(1 个或多个):调用方唯一能看到的部分,没有函数体
  2. 实现签名(有且只有 1 个):唯一带函数体的,对调用方完全不可见
  3. 解析规则:自上而下,取第一个匹配的重载签名
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ① 重载签名:调用方看到的是这三行
function format(value: Date): string;
function format(value: number): string;
function format(value: string): string;
// ② 实现签名:唯一有函数体,调用方看不见
function format(value: Date | number | string): string {
  if (value instanceof Date) return value.toISOString();
  if (typeof value === 'number') return value.toFixed(2);
  return value.trim();
}

format(new Date()); // ✅
format(3.14);       // ✅
format(true);       // ❌ 没有匹配的重载——哪怕实现签名里加了 boolean 也救不了

最后那行是关键:实现签名不参与调用匹配。这是新手最常踩的坑。

6. 解析顺序:具体到宽泛,写反了就出”死重载”

1
2
3
4
5
6
7
8
9
10
11
// ❌ 宽泛的写前面,后面永远走不到
function getBad(key: string): unknown;
function getBad(key: 'count'): number; // 死代码
function getBad(key: string): unknown { return store[key]; }
getBad('count'); // 返回 unknown,第二条签名白写了

// ✅ 从具体到宽泛,兜底放最后
function get(key: 'count'): number;
function get(key: 'label'): string;
function get(key: string): unknown; // 兜底
function get(key: string): unknown { return store[key]; }

面试一句话:重载按书写顺序自上而下匹配,取第一个命中的;所以具体签名必须写在宽泛签名前面。

7. 什么时候该用重载?先问自己:返回类型变不变

官方的态度很明确:能用联合类型就别用重载。判据只有一条——

  • 返回类型随参数变化 → 用重载(createElement('img') 返回 HTMLImageElement)
  • 返回类型不随参数变化 → 用联合参数(更省事,还更好用)
  • 输入输出同型 → 用泛型(identity<T>(x: T): T)
1
2
3
4
5
6
7
8
9
10
11
// ❌ 返回类型都是 number,根本不需要重载;而且这么写还埋了个雷
function len(s: string): number;
function len(s: unknown[]): number;
function len(s: string | unknown[]): number { return s.length; }

len(Math.random() > 0.5 ? 'hi' : [1]);
// ❌ 报错!TS 一次只能把调用解析到某一个重载上,没有一个重载能吃下 string | number[]

// ✅ 一个签名搞定,且支持传联合
function len(s: string | unknown[]): number { return s.length; }
len(Math.random() > 0.5 ? 'hi' : [1]); // ✅

这个例子值得背下来,面试官问”重载有什么缺点”时直接甩它。

8. 方法重载与构造器重载

同样的套路在 class 里也成立,事件总线是最典型的场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class EventBus {
  on(event: 'click', handler: (x: number, y: number) => void): this;
  on(event: 'error', handler: (err: Error) => void): this;
  // 实现签名:宽泛版本,调用方看不见
  on(event: string, handler: (...args: any[]) => void): this {
    // 存起来
    return this;
  }
}

const bus = new EventBus();
bus.on('click', (x, y) => console.log(x, y));      // ✅ x、y 类型精确
bus.on('error', (err) => console.error(err.message)); // ✅
bus.on('unknown', () => {});                        // ❌ 没有匹配重载

9. 重载的三个必踩坑(面试高频追问)

  1. 实现签名不可被直接调用——规范要求至少写两个重载签名,只写一个没意义。
  2. 重载不接受”联合参数”调用——见第 7 节,TS 一次只能解析到单个签名。
  3. 重载是编译期概念,编译后被完全擦除——产物里只有一个普通 JS 函数,运行时的分支是你在实现体里用 typeof / instanceof 手写的。TS 里没有 Java/C# 那种按参数类型的运行时分派。

其实你每天都在用

  • document.createElement('img') 返回 HTMLImageElement——标准库里最经典的重载,写 'div' 就返回 HTMLDivElement
  • addEventListener('click', e => ...) 里 e 自动是 MouseEvent,靠的是 K extends keyof HTMLElementEventMap 这套约束
  • querySelector<E extends Element>(sel: string): E | null——约束 + 显式类型参数的组合
  • axios.get<User>(url) 直接拿到 Promise<AxiosResponse<User>>,不用再 as User
  • useState<string>('')——React 里最常见的显式泛型
  • Lodash 的 get(obj, 'a.b.c') 路径自动补全,底层就是 keyof 约束
  • Vue 的 ref<T>() / defineProps<Props>()、Pinia 的 defineStore<Id, State>
  • 自己封装的 request<T>(url): Promise<T>——如果它让你每次都要写 as,说明泛型位置就没用对

常见误解(FAQ)

❌ 误区1:”函数重载是运行时多态,TS 会按参数类型自动挑合适的函数” 不对。重载签名在编译后被完全擦除,最终只有一个 JS 函数。运行时的分支是你在实现体里用 typeof / instanceof 手写的,TS 不做任何运行时分派。

❌ 误区2:”实现签名外部也能调用” 不能。实现签名对调用方完全不可见,它只是”内部管线”——必须宽到能吃下所有重载签名,但永远不会被单独选中。

❌ 误区3:”重载写好了,传联合类型的参数也能匹配” 这是最容易翻车的一条。TS 一次只能把一次调用解析到某一个重载签名上。所以官方建议:返回类型不变的场景,用联合参数而不是重载。

❌ 误区4:”泛型约束就是限制,约束加得越多越安全” 约束的第一作用是赋能——没有约束,你在泛型函数里对这个值什么都干不了。反过来说,过度约束(T extends A & B & C & D)会让调用方八成的类型都传不进来。约束应该”刚好够用”。

❌ 误区5:”泛型里的 extends 和 interface 继承的 extends 是一回事” 不是。interface A extends B 是继承成员;T extends U 是可赋值性判断(T 是不是 U 的子类型),而 U 完全可以是联合类型、字面量类型、keyof 的结果,甚至是个函数类型,不一定非得是对象。

❌ 误区6:”泛型搞不定就上 as any,能过就行” as any 是关掉类型检查,不是解决问题。真搞不定的时候按这个顺序找答案:联合类型 → 泛型 → 条件类型 → 重载,最后才考虑断言,而且断言要集中在一处并写明原因。

一句话总结

约束让”什么能传进来”在编译期定死,重载让”传进来会得到什么”在调用处变精确;能用一个签名讲清的事别写重载,能用联合讲清的事更别写重载。

本文由作者按照 CC BY 4.0 进行授权