学习TypeScript的函数重载

163 阅读8分钟

随着条件类型或变量元组类型等最新类型系统功能的出现,一种描述函数接口的技术已经淡出了人们的视野。函数重载。而这是有原因的。这两个特性都是为了处理常规函数重载的缺点而实现的。

请看这个直接来自TypeScript 4.0发布说明的连接例子。这是一个数组concat 函数。

function concat(arr1, arr2) {
  return [...arr1, ...arr2];
}

如果要正确输入这样的函数,使其考虑到所有可能的边缘情况,我们最终会陷入重载的海洋。

// 7 overloads for an empty second array
function concat(arr1: [], arr2: []): [];
function concat<A>(arr1: [A], arr2: []): [A];
function concat<A, B>(arr1: [A, B], arr2: []): [A, B];
function concat<A, B, C>(arr1: [A, B, C], arr2: []): [A, B, C];
function concat<A, B, C, D>(arr1: [A, B, C, D], arr2: []): [A, B, C, D];
function concat<A, B, C, D, E>(arr1: [A, B, C, D, E], arr2: []): [A, B, C, D, E];
function concat<A, B, C, D, E, F>(arr1: [A, B, C, D, E, F], arr2: []): [A, B, C, D, E, F];)
// 7 more for arr2 having one element
function concat<A2>(arr1: [], arr2: [A2]): [A2];
function concat<A1, A2>(arr1: [A1], arr2: [A2]): [A1, A2];
function concat<A1, B1, A2>(arr1: [A1, B1], arr2: [A2]): [A1, B1, A2];
function concat<A1, B1, C1, A2>(arr1: [A1, B1, C1], arr2: [A2]): [A1, B1, C1, A2];
function concat<A1, B1, C1, D1, A2>(arr1: [A1, B1, C1, D1], arr2: [A2]): [A1, B1, C1, D1, A2];
function concat<A1, B1, C1, D1, E1, A2>(arr1: [A1, B1, C1, D1, E1], arr2: [A2]): [A1, B1, C1, D1, E1, A2];
function concat<A1, B1, C1, D1, E1, F1, A2>(arr1: [A1, B1, C1, D1, E1, F1], arr2: [A2]): [A1, B1, C1, D1, E1, F1, A2];
// and so on, and so forth

而这只考虑到了最多有六个元素的数组。变量元组类型对这样的情况有很大帮助。

type Arr = readonly any[];
 
function concat<T extends Arr, U extends Arr>(arr1: T, arr2: U): [...T, ...U] {
  return [...arr1, ...arr2];
}

你可以很容易地看到如何将函数签名归结为它的要点,同时对所有可能出现的数组有足够的灵活性。返回值也映射到返回类型。没有额外的断言,TypeScript可以确保你返回的是正确的值。

这是一个类似于条件类型的情况。这个例子直接来自我的。想想那些根据客户、文章或订单ID检索订单的软件。你可能想创建这样的东西。

function fetchOrder(customer: Customer): Order[]
function fetchOrder(product: Product): Order[]
function fetchOrder(orderId: number): Order
// the implementation
function fetchOrder(param: any): Order | Order[] {
  //...
}

但这只是事实的一半。如果你最终遇到了模糊的类型,你不知道到底是得到一个客户,还是只得到一个产品,怎么办。你需要照顾到所有可能的组合。

function fetchOrder(customer: Customer): Order[]
function fetchOrder(product: Product): Order[]
function fetchOrder(orderId: number): Order
function fetchOrder(orderId: Customer | Product): Order[]
function fetchOrder(orderId: Customer | number): Order | Order[]
function fetchOrder(orderId: number | Product): Order | Order[]
// the implementation
function fetchOrder(param: any): Order | Order[] {
  //...
}

增加更多的可能性,你最终会有更多的组合。在这里,条件类型可以极大地减少你的函数签名。

type FetchParams = number | Customer | Product;

type FetchReturn<T> = T extends Customer ? Order[] :
  T extends Product ? Order[] : 
  T extends number ? Order: never

function fetchOrder<T extends FetchParams>(params: T): FetchReturn<T> {
  //...
}

由于条件类型分布在一个联合体中,FetchReturn 返回一个返回类型的联合体。

因此,有充分的理由使用这些技术而不是淹没在太多的函数重载中。这就产生了一个问题。我们还需要函数重载吗?

TL;DR:是的,我们需要函数重载。

这里有几个例子。

不同的函数形状#

函数重载仍然非常方便的一种情况是,如果你的函数变体有不同的参数列表。这意味着不仅参数(参数)本身可以有一些变化(这就是条件式和变体图元的奇妙之处),而且参数的数量和位置也是如此。

想象一下,一个搜索函数有两种不同的被调用方式。

  1. 用搜索查询来调用它。它返回一个你可以等待的Promise
  2. 用搜索查询和一个回调调用它。在这种情况下,该函数不返回任何东西。

可以用条件类型来完成,但非常不方便。

// => (1)
type SearchArguments = 
  // Argument list one: a query and a callback
  [query: string, callback: (results: unknown[]) => void] |
  // Argument list two:: just a query
  [query: string];

// A conditional type picking either void or a Promise depending
// on the input => (2)
type ReturnSearch<T> = T extends [query: string] ? Promise<Array<unknown>> : void;

// the actual function => (3)
declare function search<T extends SearchArguments>(...args: T): ReturnSearch<T>;

// z is void
const z = search("omikron", (res) => {

})

// y is Promise<unknown>
const y = search("omikron")

下面是我们的做法。

  1. 我们使用元组类型来定义我们的参数列表。从TypeScript 4.0开始,我们可以给元组字段命名,就像我们对对象那样做。我们创建了一个联盟,因为我们的函数签名有两个不同的变体
  2. ReturnSearch ,根据参数列表的变体来选择返回类型。如果它只是一个字符串,就返回Promise,如果它有一个回调,就返回void。
  3. 我们通过将一个泛型变量约束到SearchArguments ,来添加我们的类型,这样我们就可以正确地选择返回类型。

这是个很大的问题!而且它具有大量复杂的功能,我们喜欢在TypeScript的功能列表中看到。条件类型、泛型、泛型约束、元组类型、联合类型!我们得到了一些不错的自动完成功能,但它远没有一个简单的函数重载那么清晰。

function search(query: string): Promise<unknown[]>
function search(query: string, callback: (result: unknown[]) => void): void
// This is the implementation, it only concerns you
function search(query: string, callback?: (result: unknown[]) => void): void | Promise<unknown> {
  // Implmeent
}

我们只在实现部分使用一个联合类型。其余的部分是非常明确和清晰的。我们知道我们的参数,我们知道期望得到什么回报。没有仪式,只有简单的类型。函数重载最好的部分是,实际的实现并不污染类型空间。你可以进行一轮任意运算,而不需要关心。

精确参数#

另一种情况是,当你需要精确的参数和它们的映射时,函数重载可以使很多事情变得简单。让我们来看看一个将事件应用到事件处理程序的函数。例如,我们有一个MouseEvent ,想用它来调用一个MouseEventHandler 。键盘事件也是如此,等等。如果我们使用条件和联合类型来映射事件和处理程序,我们最终可能会得到这样的结果。

// All the possible event handlers
type Handler = 
  MouseEventHandler<HTMLButtonElement> | 
  KeyboardEventHandler<HTMLButtonElement>;

// Map Handler to Event
type Ev<T> = 
  T extends MouseEventHandler<infer R> ? MouseEvent<R> :
  T extends KeyboardEventHandler<infer R> ? KeyboardEvent<R> : never;

// Create a
function apply<T extends Handler>(handler: T, ev: Ev<T>): void {
  handler(ev as any); // We need the assertion here
}

乍一看,这看起来不错。但如果你考虑到你需要跟踪的所有变体,这可能会有点麻烦。

不过,还有一个更大的问题。TypeScript处理所有可能的事件变体的方式导致了一个*意外的交叉*。这意味着在函数体中,TypeScript无法知道你传递的是哪种处理程序。因此,它也不能告诉我们得到的是哪种事件。所以TypeScript说,这个事件可以是两个。一个鼠标事件,和一个键盘事件。你需要传递能够处理这两种事件的处理程序。这不是我们打算让我们的函数工作的方式。

实际的错误信息是TS 2345:"键盘事件|MouseEvent<HTMLButtonElement, MouseEvent>"类型的参数不能分配给 "MouseEvent<HTMLButtonElement, MouseEvent> & KeyboardEvent "的参数。

这就是为什么我们需要一个as any 类型的断言。只是为了使它能够真正调用事件的处理程序。

所以,这个函数签名在很多情况下都是有效的。

declare const mouseHandler: MouseEventHandler<HTMLButtonElement>;
declare const mouseEv: MouseEvent<HTMLButtonElement>
declare const keyboardHandler: KeyboardEventHandler<HTMLButtonElement>;
declare const keyboardEv: KeyboardEvent<HTMLButtonElement>;

apply(mouseHandler, mouseEv); // yeah!
apply(keyboardHandler, keyboardEv) // cool!
apply(mouseHandler, keyboardEv) // 💥breaks like it should!

但是一旦有了歧义,事情就不像它应该做的那样了。

declare const mouseOrKeyboardHandler:
  MouseEventHandler<HTMLButtonElement> | 
  KeyboardEventHandler<HTMLButtonElement>;;

// No wait, this can cause problems!
apply(mouseOrKeyboardHandler, mouseEv);

mouseOrKeyboardHandler 是一个键盘处理程序时,我们不能合理地传递一个鼠标事件。等一下。这正是上面的TS2345错误所要告诉我们的我们只是把问题转移到了另一个地方,用一个 "任意"的断言让它沉默。哦,不!

明确的、准确的函数签名使一切变得更容易。映射变得更清晰,类型签名更容易理解,而且不需要条件式或联合式。

// Overload 1: MouseEventHandler and MouseEvent
function apply(
  handler: MouseEventHandler<HTMLButtonElement>, 
  ev: MouseEvent<HTMLButtonElement>): void
// Overload 2: KeyboardEventHandler and KeyboardEvent
function apply(
  handler: KeyboardEventHandler<HTMLButtonElement>,
  ev: KeyboardEvent<HTMLButtonElement>): void
// The implementation. Fall back to any. This is not a type! 
// TypeScript won't check for this line nor 
// will it show in the autocomplete. 
//This is just for you to implement your stuff.
function apply(handler: any, ev: any): void {
	handler(ev);
}

函数重载帮助我们处理所有可能的情况。我们基本上可以确保没有模棱两可的类型。

apply(mouseHandler, mouseEv); // yeah!
apply(keyboardHandler, keyboardEv) // cool!
apply(mouseHandler, keyboardEv) // 💥 breaks like it should!
apply(mouseOrKeyboardHandler, mouseEv); // 💥 breaks like it should

对于实现,我们甚至可以使用任何。这不是TypeScript所看到的类型,这只是为你实现你的东西。既然你能确保不会遇到隐含歧义的情况,那么我们就可以依靠快乐的类型,不需要再费心了。

底线#

函数重载仍然是非常有用的,而且对于很多情况下来说,它是一种方式。它们更容易阅读,更容易编写,而且在很多情况下比我们用其他方法得到的东西更精确。

但这并不是非此即彼。如果你的情况需要,你可以愉快地混合和匹配条件语句和函数重载。