类型体操(三):Promisify 与函数类型运算
本节目标
- 写一个「把回调风格函数变成 Promise 风格」的类型
Promisify<F> - 综合运用「函数类型 + 条件类型 + infer」拆装函数签名
- 认识一组 TS 内置的函数类型运算:
Parameters/ReturnType/ConstructorParameters/InstanceType - 收尾:类型体操的能力边界
1. 问题:回调 → async/await
Node 老 API 大量用回调:(id, cb: (err, res) => void) => void。我们要推导它的 Promise 版本返回类型——也就是「去掉最后一个回调参数,返回 Promise<res>」:
// 运行环境:Node.js 16+ + typescript;类型检查 npx tsc --noEmit ts-l18.ts
type NodeFn = (id: number, cb: (err: Error | null, res: string) => void) => void
// 期望推导成: (id: number) => Promise<string>2. Promisify 的拆解思路
我们要做三件事:① 用 infer 把整个参数元组抓出来;② 去掉最后一个(回调);③ 从回调签名里抠出 res 类型。
// 取「去掉最后一个」的参数(剥掉 cb)
type RemoveLast<T extends any[]> =
T extends [...infer Rest, any] ? Rest : []
// cb 是 (err, res) => void,取 res(最后一个参数)
type LastCbResult<T extends any[]> =
T extends [...any[], (err: any, res: infer R) => void] ? R : never
// Promisify:把 (...args, cb) => void 变成 (...args) => Promise<res>
type Promisify<F> =
F extends (...args: infer Args) => void
? (...args: RemoveLast<Args>) => Promise<LastCbResult<Args>>
: never
type P = Promisify<NodeFn> // (id: number) => Promise<string> ✅手推一遍(以 NodeFn 为例):
Args = [number, (err, res) => void] // infer 抓出整个参数元组
RemoveLast<Args> = [number] // 剥掉最后一个(回调)
LastCbResult<Args> = string // 从回调 (err, res)=>void 抠出 res
=> (...args: [number]) => Promise<string>
=> (id: number) => Promise<string>这里把「函数参数用 infer 拆开、用条件类型重组」玩到了极致:RemoveLast 剥参数,LastCbResult 从回调签名里抠 res。
3. 一组内置函数类型运算
你刚才手搓的 Promisify,底层用的思想 TS 早就封装好了。认识这几个内置工具类型,能省大量手搓:
type Fn = (a: string, b: number) => boolean
type P0 = Parameters<Fn> // [string, number] 参数元组
type R0 = ReturnType<Fn> // boolean 返回值
type C0 = ConstructorParameters<typeof Date> // [number?] 构造函数的参数
type I0 = InstanceType<typeof Date> // Date 实例类型
// 用内置工具改写一个简化版 Promisify(只处理 res 无 err 的回调)
type PromisifySimple<F> =
F extends (cb: (res: infer R) => void) => void
? () => Promise<R>
: neverParameters<T> 本质是 T extends (...args: infer A) => any ? A : never;ReturnType<T> 本质是 T extends (...args: any) => infer R ? R : never。你写的 Promisify 其实就是这两个内置类型的「组合定制版」。
4. 进阶玩法:柯里化(Curry)
函数类型运算不止 Promise 化,还能「把一个多参函数变成一连串单参函数」:
// Curry:把 (a, b, c) => R 变成 (a) => (b) => (c) => R
type Curry<F> =
F extends (...args: infer A) => infer R
? A extends [infer H, ...infer Rest]
? Rest extends []
? (h: H) => R // 只剩一个参数,直接返回 R
: (h: H) => Curry<(...args: Rest) => R>
: () => R
: never
declare function add(a: number, b: number, c: number): number
type CurriedAdd = Curry<typeof add> // (a: number) => (b: number) => (c: number) => number这个例子综合了「参数元组拆解 + 递归返回函数」,是函数类型运算里比较烧脑的一类,但思路依然是「infer 拆开、条件类型重组」。
5. 能力边界(重要)
类型体操再强,也只能描述「应该是什么」,不能替你写运行时代码。比如 Promisify 推导出了 (id: number) => Promise<string>,但真正把回调包成 Promise 还得靠 util.promisify(Node 自带)或你手写的包装函数。类型层算出来的签名,要由运行时实现去兑现。
名词解释
函数类型运算:把「函数的参数、返回值」当类型拆开再拼装。配合 infer Args(捕获参数元组)、条件类型,你能凭一个旧函数类型「推导出」它的变种(Promise 版、柯里化版、节流版……)。这是类型体操里最烧脑也最实用的一类。
Parameters / ReturnType:TS 内置的两个函数类型运算工具。Parameters<F> 抓出参数元组,ReturnType<F> 抓出返回值。它们本质是「用 infer 在函数的 (...args) => R 结构里分别捕获 args 和 R」。几乎所有函数变换类型都建立在它们之上。
Promisify 的意义:Node 旧 API 大量用回调 (err, res),而现代代码用 async/await。Promisify 类型让你「包装函数时,返回类型自动变成 Promise」,调用方无需手动标注。TS 内置的 Parameters<T>、ReturnType<T> 就是这类运算的现成工具。
课后练习
练习 1:用内置 Parameters<T> 和 ReturnType<T> 改写 Promisify 的简化版(只处理无 err 的回调)。
答案:
type PromisifySimple<F> = F extends (cb: (res: infer R) => void) => void ? () => Promise<R> : never
练习 2:Promisify 类型推导再强,能保证运行时真的返回 Promise 吗?
答案:不能。类型只描述「应该是什么」,真正把回调包成 Promise 要靠运行时代码(如
util.promisify)。类型体操负责「让调用方拿到正确类型」,实现得另外写。
练习 3:用 ReturnType 写出「取一个函数的返回值类型」的式子(不要用 infer 手写,直接用内置工具)。给定 type F = () => { ok: boolean },求结果类型。
答案:
ReturnType<F>→{ ok: boolean }。内置工具已经帮你 infer 好了,这就是用现成工具比手搓省事的地方。
练习 4:Curry 在「只剩一个参数」时为什么直接返回 R 而不是继续 (h: H) => ...?
答案:因为递归基线应该是「参数用完了」。当
Rest为空数组([])时,说明没有更多参数要柯里化,直接返回原函数的最终返回值R即可;否则会多包一层无意义的(h) => R。
总结
这一节是类型体操的「结业礼」,综合了函数类型、infer、条件分发。我的观点很明确:这类高级类型「能推导」不代表「该手写」。实际项目里 util.promisify(Node 自带)和库里的现成类型已经覆盖了 99% 场景,你很少需要手搓 Promisify。但练它的意义在于——你从此读懂了标准库 Parameters/ReturnType/ThisParameterType 的实现,也拥有了「当业务真需要定制函数变换类型时」的能力。一句话收尾类型体操:它是锋利的刀,用在通用工具库里是神兵,用在业务组件里是自找麻烦。