TypeScript 函数重载

一个取数 hook,要同时服务有参数的接口和无参数的接口:

useResource(instanceApi.metadata, name)   // 有参数
useResource(commonApi.projects)           // 无参数

把第二个参数写成可选,这两行都能编译:

type ResourceResult<T> = { data: T | null; error: Error | null; isLoading: boolean }
 
function useResource<T, U>(
  fetcher: (params?: U, signal: AbortSignal) => Promise<T>,
  params?: U,
): ResourceResult<T>

代价是 useResource(instanceApi.metadata) 也能编译。运行时 name 是 undefined,请求拼成 /instances/undefined/metadata。类型没能拦住本来该拦的那个调用。

重载解决的就是这个问题:同一个函数,有几种调用形状,就写几套签名。

三段结构

// 重载签名 1:无参接口
export function useResource<T>(fetcher: (signal: AbortSignal) => Promise<T>): ResourceResult<T>
 
// 重载签名 2:有参接口
export function useResource<T, U>(
  fetcher: (params: U, signal: AbortSignal) => Promise<T>,
  params: U,
): ResourceResult<T>
 
// 实现签名:有且只有一个,必须紧跟重载
export function useResource<T, U>(
  fetcher: (params: U | undefined, signal: AbortSignal) => Promise<T>,
  params?: U,
): ResourceResult<T> {
  // 用 params 和 signal 发请求,把结果写进 state
}

重载签名不能有函数体,实现签名必须有。实现签名紧跟在最后一个重载后面,中间不能插别的语句,否则报 TS2391。

实现签名对外不可见,调用方只能看到重载签名。只写一条重载时,两个参数的调用会报 Expected 1 arguments, but got 2:在调用方眼里这个函数只接受一个参数,实现签名那条不在候选里。

调用解析

编译器把重载签名按声明顺序排列,从上往下尝试,第一个参数匹配上的生效,返回值也从它来。

function parse(input: string): string[]
function parse(input: string[]): string
 
parse('a,b')   // string[]
parse(['a'])   // string

顺序就是优先级。 宽泛的签名放在前面会吞掉后面所有调用:

function f(x: unknown): string    // f([1]) 也会落到这里
function f(x: number[]): string[]

没有“最接近”匹配。 第一个能匹配的胜出,不比较谁更精确。所有重载都不匹配时,报错通常指向最后一条重载,看起来和实际错误没什么关系。签名越多,调用错误越难读,这是重载的代价。

实现签名要比所有重载宽

TypeScript 会检查:按每一条重载的方式调用,实现签名都必须接得住。接不住报 TS2394「此重载签名与其实现签名不兼容」。

下面这个实现签名,第二个参数是必填的:

export function useResource<T>(fetcher: (signal: AbortSignal) => Promise<T>): ResourceResult<T>
 
export function useResource<T, U>(
  fetcher: (params: U | undefined, signal: AbortSignal) => Promise<T>,
  params: U,
): ResourceResult<T>

重载 1 调用时只传一个参数,实现却要求两个,两边对不上。把实现签名的参数放宽即可:

params?: U,

“宽”体现在三处:参数可选、参数类型带上 | undefined、返回类型比重载更粗。实现签名不负责对外表达,只做两件事:检查函数体、兜住所有重载。对外契约由重载签名负责。

泛型和重载

类型参数可以写在每一条重载上,各自独立。调用时只有被选中的那条签名参与推断。

function pick<T>(arr: T[]): T
function pick<T, K extends keyof T>(obj: T, key: K): T[K]

ahooks 的 useRequest 用两个重载并存两种推断方式:

// 从 service 函数推断 Data 和 Params,正常调用走这条
function useRequest<TService extends AnyService>(
  service: TService,
  options?: Options<InferData<TService>, InferParams<TService>>,
): Result<InferData<TService>, InferParams<TService>>
 
// 用户显式指定泛型:useRequest<User, [string]>(fetchUser)
function useRequest<TData, TParams extends any[]>(
  service: Service<TData, TParams>,
  options?: Options<TData, TParams>,
): Result<TData, TParams>

库要兼容历史调用方式,旧签名不能删,新签名只能加。重载是同时表达这两种方式的常规写法。

什么时候值得写重载

参数形状不同,返回类型跟着变。 DOM 的 createElement:

createElement<K extends keyof HTMLElementTagNameMap>(tagName: K): HTMLElementTagNameMap[K]
createElement(tagName: string): HTMLElement

传 'canvas' 返回 HTMLCanvasElement,传任意字符串返回 HTMLElement。

参数个数不同。 上面的 useResource:有参接口传两个,无参接口传一个。

库需要兼容旧调用方式。 前面的 useRequest。

反过来,一个联合类型签名能表达的,不需要重载:

function format(value: Date | number, unit?: 'ms' | 's'): string

几种常见方案的取舍:

方案适合代价
联合类型参数参数形状一致,只是类型不同表达不了形状差异
泛型默认值 P = undefined只差一个可省略的参数漏传参数拦不住
条件 rest 元组参数有无随类型变化,想保持单签名实现里要一次类型断言
函数重载多种调用形状、返回类型随输入变签名重复,报错难读

条件 rest 元组长这样:

type Args<P> = P extends undefined ? [] | [params: P] : [params: P]
 
function useResource<T, P = undefined>(
  fetcher: (params: P, signal: AbortSignal) => Promise<T>,
  ...args: Args<P>
)

它能做到和重载相同的效果,实现体里要把 args[0] 断言成 P——编译器不会从“空元组意味着 P 是 undefined”反推。重载没有这份断言,代价是多写几行签名。

运行时没有重载

类型重载只在编译期存在。运行时只有一个函数,参数怎么解释由实现体自己判断:

function parse(input: string | string[]) {
  // 是字符串还是数组,由这个函数自己区分
}

箭头函数不能重载,得用函数声明,或者把签名写进接口:

interface UseResource {
  <T>(fetcher: (signal: AbortSignal) => Promise<T>): ResourceResult<T>
  <T, U>(fetcher: (params: U, signal: AbortSignal) => Promise<T>, params: U): ResourceResult<T>
}
 
const useResource: UseResource = (fetcher: any, params?: any) => { ... }

结语

重载是类型层的多套契约,配一个运行时实现。对外给调用方最精确的签名,对内用更宽的签名兜住全部调用。

业务代码里出现第二种调用形状时再引入。只有一种形状时,联合类型、可选参数或者默认泛型通常更短;重载的收益在调用方,签名数量、报错质量这些成本由维护者承担。