函数是 TypeScript 里类型最复杂的构造之一:参数的变型规则、this 的类型、重载签名与实现签名的分离,每一条都有反直觉的地方。
参数逆变与返回值协变
1// 函数参数的逆变(contravariance)
2// 参数类型更宽的函数,可以赋值给参数类型更窄的位置
3
4interface BaseEvent { type: string }
5interface ClickEvent extends BaseEvent { x: number; y: number }
6
7type Handler = (e: ClickEvent) => void;
8
9// BaseEvent 比 ClickEvent 宽,所以可以当 ClickEvent 的处理器
10const wide: Handler = (e: BaseEvent) => { void e; };
11
12// 反过来不行:只接受 ClickEvent 的函数不能处理任意 BaseEvent
13// @ts-expect-error TS2322: 参数类型不兼容
14const narrow: (e: BaseEvent) => void = (e: ClickEvent) => { void e; };
15
16// 返回值是协变的:返回类型更窄的函数可以赋给更宽的位置
17type Factory = () => BaseEvent;
18const specific: Factory = () => ({ type: "click", x: 1, y: 2 } as ClickEvent);
19
20// 方法简写 vs 函数属性:bivariance 差异(strictFunctionTypes 只作用于属性写法)
21interface WithMethod {
22 handle(e: BaseEvent): void; // 方法简写:双变(bivariant)
23}
24interface WithProperty {
25 handle: (e: BaseEvent) => void; // 属性写法:逆变(contravariant)
26}
27
28// 方法简写更宽松:允许传"更窄参数"的函数(双变的宽松方向)
29const m: WithMethod = { handle: (e: ClickEvent) => { void e; } };
30
31// 属性写法在 strictFunctionTypes 下拒绝同样的写法
32// @ts-expect-error TS2322: 属性写法要求参数逆变
33const p: WithProperty = { handle: (e: ClickEvent) => { void e; } };
34
35// 两种写法都允许"更宽参数"(逆变方向,安全)
36const m2: WithMethod = { handle: (e: { type: string }) => { void e; } };
37const p2: WithProperty = { handle: (e: { type: string }) => { void e; } };
38
39// 回调场景:参数能宽不能窄
40function onClick(cb: (e: ClickEvent) => void): void {
41 cb({ type: "click", x: 1, y: 2 });
42}
43onClick((e) => { void e; }); // e 推断为 ClickEvent
44onClick((e: BaseEvent) => { void e; }); // 显式写宽也可以
45// onClick((e: ClickEvent & { z: 1 }) => {}); // 更窄不行
46
47console.log(typeof wide, typeof narrow, typeof specific, typeof m, typeof p, typeof m2, typeof p2, typeof onClick);
1function function function object object object object function
核心规则:参数类型更宽的函数,可以赋值给参数类型更窄的位置。
1interface BaseEvent { type: string }
2interface ClickEvent extends BaseEvent { x: number; y: number }
3
4type Handler = (e: ClickEvent) => void;
5
6const wide: Handler = (e: BaseEvent) => {}; // ✓ 参数更宽,安全
7const narrow: (e: BaseEvent) => void = (e: ClickEvent) => {};
8// error TS2322: Types of parameters 'e' and 'e' are incompatible.
为什么?因为 Handler 会被用 ClickEvent 调用。如果实现只接受 ClickEvent,那没问题;但如果实现接受 BaseEvent,也能处理 ClickEvent(因为它是子类型)。反过来,如果实现只接受比 ClickEvent 更窄的类型,就无法处理所有 ClickEvent。
实测错误消息:
1error TS2322: Type '(e: BaseEvent) => void' is not assignable to type '(e: ClickEvent) => void'.
2 Types of parameters 'e' and 'e' are incompatible.
3 Property 'type' is missing in type 'ClickEvent' but required in type 'BaseEvent'.
返回值方向相反——返回更窄类型是安全的:
1type Factory = () => BaseEvent;
2const specific: Factory = () => ({ type: "click", x: 1, y: 2 }); // ✓ 返回子类型
方法简写 vs 属性写法
这是 strictFunctionTypes 的一个例外,很容易踩:
1interface WithMethod {
2 handle(e: BaseEvent): void; // 方法简写:双变(bivariant)
3}
4interface WithProperty {
5 handle: (e: BaseEvent) => void; // 属性写法:逆变
6}
7
8// 方法简写允许传"更窄参数"的函数
9const m: WithMethod = { handle: (e: ClickEvent) => {} };
10
11// 属性写法拒绝
12const p: WithProperty = { handle: (e: ClickEvent) => {} };
13// error TS2322: Type '(e: ClickEvent) => void' is not assignable to type '(e: BaseEvent) => void'.
结论:方法简写比属性写法宽松。这是历史原因(为了让 Array 等内置类型的方法可用),但意味着:
如果类型安全对你很重要,把接口里的方法写成属性形式 handle: (e: T) => void,而不是方法简写 handle(e: T): void。strictFunctionTypes 只对属性写法生效。
this 参数
1// this 参数:显式声明 this 的类型
2
3// 普通函数里的 this 默认是 any(noImplicitThis 下报错)
4function bad() {
5 // @ts-expect-error TS2683: 'this' 隐式具有 any 类型
6 return this.value;
7}
8
9// 用 this 参数显式声明(必须是第一个参数,只是类型声明不占实参位)
10function good(this: { value: number }): number {
11 return this.value;
12}
13
14// 调用时用 call/apply 绑定
15const result = good.call({ value: 1 });
16
17// 对象方法里的 this:TS 能推断出来
18const obj = {
19 value: 42,
20 get() {
21 return this.value; // this 推断为 obj 的类型
22 },
23};
24
25// 类里的 this 是实例类型
26class Counter {
27 value = 0;
28 inc(): number {
29 return ++this.value;
30 }
31}
32
33// this 参数与泛型结合:链式调用
34interface Chainable {
35 add(this: Chainable, n: number): this;
36}
37
38// 事件处理器风格的 this
39interface Widget {
40 el: string;
41 onClick(this: Widget, e: { type: string }): void;
42}
43
44const widget: Widget = {
45 el: "div",
46 onClick(e) { console.log(this.el, e.type); },
47};
48widget.onClick({ type: "click" });
49
50// 显式排除 this 的使用:this: void
51function standalone(this: void, x: number): number {
52 return x * 2;
53}
54
55// 类型工具:ThisParameterType / OmitThisParameter
56type ThisType = ThisParameterType<typeof good>; // { value: number }
57type NoThis = OmitThisParameter<typeof good>; // () => number
58const noThis: NoThis = good.bind({ value: 1 });
59
60const thisTypeCheck: ThisType = { value: 1 };
61
62console.log(result, obj.get(), new Counter().inc(), standalone(2), noThis(), thisTypeCheck.value, bad.call({ value: 3 }));
1div click
21 42 1 4 1 1 3
this 默认是 any,noImplicitThis(strict 包含)下报错:
1function bad() {
2 return this.value;
3 // error TS2683: 'this' implicitly has type 'any' because it does not have a
4 // type annotation.
5}
显式声明用伪参数语法——它写在参数列表第一位,但不占实参位置:
1function good(this: { value: number }): number {
2 return this.value;
3}
4
5good.call({ value: 1 }); // 用 call/apply 绑定
6// good({ value: 1 }); // 错误:this 参数不占实参位
this: void 表示"这个函数不能用 this":
1function standalone(this: void, x: number): number { return x * 2; }
两个类型工具:
1type T = ThisParameterType<typeof good>; // { value: number }
2type N = OmitThisParameter<typeof good>; // () => number
函数重载
1// 函数重载:多个调用签名 + 一个实现签名
2
3// 重载签名(对调用方可见)
4function format(value: string): string;
5function format(value: number): string;
6function format(value: Date): string;
7// 实现签名(对调用方不可见,必须兼容所有重载)
8function format(value: string | number | Date): string {
9 if (typeof value === "string") return value.toUpperCase();
10 if (typeof value === "number") return value.toFixed(2);
11 return value.toISOString();
12}
13
14// 重载顺序很重要:先匹配到的优先
15function pick(x: string): "string";
16function pick(x: number): "number";
17function pick(x: string | number): "string" | "number" {
18 return typeof x === "string" ? "string" : "number";
19}
20
21// 顺序反了会导致宽签名先匹配,窄签名永远用不上
22function badPick(x: string | number): "wide";
23function badPick(x: string): "narrow";
24function badPick(x: string | number): "wide" | "narrow" {
25 return typeof x === "string" ? "narrow" : "wide";
26}
27const badResult = badPick("a"); // 推断为 "wide",不是 "narrow"
28
29// 对象方法的重载
30interface Parser {
31 parse(text: string): number;
32 parse(text: number): string;
33}
34
35const parser: Parser = {
36 parse(text: string | number): any {
37 return typeof text === "string" ? Number(text) : String(text);
38 },
39};
40
41// 用箭头函数实现重载:需要用类型断言或接口
42type Overloaded = {
43 (x: string): string;
44 (x: number): number;
45};
46const impl: Overloaded = ((x: string | number) => x) as Overloaded;
47
48// 重载 vs 联合类型:什么时候用重载
49// 用重载:返回值类型依赖入参类型
50function firstOrNull(arr: []): null;
51function firstOrNull<T>(arr: T[]): T;
52function firstOrNull<T>(arr: T[]): T | null {
53 return arr.length ? arr[0]! : null;
54}
55const r1 = firstOrNull([]); // null
56const r2 = firstOrNull([1, 2]); // number
57
58// 用联合更简单:返回值不依赖入参
59function simple(x: string | number): string {
60 return String(x);
61}
62
63console.log(format("a"), format(1), format(new Date("2020-01-01")));
64console.log(pick("a"), pick(1), badResult);
65console.log(parser.parse("1"), parser.parse(1), impl("a"), impl(1));
66console.log(r1, r2, simple("x"));
1A 1.00 2020-01-01T00:00:00.000Z
2string number narrow
31 1 a 1
重载的结构是多个调用签名 + 一个实现签名:
1function format(value: string): string; // 重载签名(调用方可见)
2function format(value: number): string;
3function format(value: Date): string;
4function format(value: string | number | Date): string { // 实现签名(调用方不可见)
5 // ...
6}
实现签名不对外可见,且必须兼容所有重载签名。
重载顺序决定匹配结果
TypeScript 按声明顺序从上往下匹配,先匹配到的生效:
1function badPick(x: string | number): "wide";
2function badPick(x: string): "narrow";
3function badPick(x: string | number): "wide" | "narrow" {
4 return typeof x === "string" ? "narrow" : "wide";
5}
6
7const r = badPick("a"); // 类型是 "wide"!
注意这里有个危险的错位:类型说 "wide",但运行时返回 "narrow"。实测确认:
1const probe: "wide" = badPick("a"); // 编译通过
2// 但运行时 badPick("a") === "narrow"
宽签名放在前面会"吃掉"所有调用,窄签名永远匹配不到。这不会报错,但会让类型和运行时行为不一致。规则:更具体的签名写在更宽泛的前面。
什么时候该用重载
| 场景 | 用什么 |
|---|---|
| 返回类型依赖入参类型 | 重载 |
| 入参数量不同 | 重载 |
| 返回值不依赖入参 | 联合类型更简单 |
1// 用重载:空数组返回 null,非空返回元素类型
2function firstOrNull(arr: []): null;
3function firstOrNull<T>(arr: T[]): T;
4function firstOrNull<T>(arr: T[]): T | null {
5 return arr.length ? arr[0]! : null;
6}
7
8// 用联合:返回值不依赖入参
9function simple(x: string | number): string { return String(x); }
用箭头函数写重载
箭头函数不能有重载签名,要用类型别名 + 断言:
1type Overloaded = {
2 (x: string): string;
3 (x: number): number;
4};
5
6const impl = ((x: string | number) => x) as Overloaded;
as 在这里是必要的,因为实现签名的类型和重载集合不完全兼容。
参数的其他形式
1function greet(name: string, title?: string): string { } // 可选参数(必须在末尾)
2function repeat(text: string, times = 2): string { } // 默认参数(自动推断)
3function sum(...nums: number[]): number { } // 剩余参数
4function dist({ x, y }: { x: number; y: number }): number { } // 解构参数(需整体注解)
解构参数必须整体注解,因为解构本身不提供类型信息。