通过curry函数类型实现,学习typescript高级类型编程

775 阅读12分钟

尽管函数柯里化和函数式编程已经越来越受青睐,但是迄今为止使用curry并进行适当的类型检查时依然很麻烦。即使是 Ramda 这样出名的库也没有为 curry 提供 泛型类型的支持(这里作者的文章较早,现在 Ramdacurry使用的是ts-toolbelt)。 如果你是一名函数式编程的程序员,你可能已经在使用 curry来创建强大的组合和部分应用程序,但是如果你有点落后,现在是时候开始摆脱命令式范式,学习函数式编程来创建更快、更轻松的解决问题,并增加代码库的可重用性。

什么是 curry

在开始之前,我们确保您对 curry有一个基本的了解,curry将一个接受多个参数的函数转换为一个一次接受一个参数的函数的过程, 让我们创建一个函数,它接受两个数字并返回它们相加的结果。

const simpleAdd = (a: number, b: number) => a + b
const test01 = simpleAdd(1, 2)  // 3

simpleAdd 柯里化之后的版本如下:

const curriedAdd = (a: number) => (b: number) => a + b
const test02 = curriedAdd(1)(2) // 3
const test03 = curriedAdd(1)    // function
const test04 = curriedAdd(1)(2) // 3
在当前文章中,我将解释如何创建与标准的 `curry`一起使用的 `typescript`类型。

然后我们将它演变为更高级的类型,这些类型可以允许柯里化接受 0 个或者更多的参数甚至可以接受占位符的函数类型。

typesciprt

在开始之前,请确保您已经对 typescript 有一定的了解,最起码能够熟练使用 typescript 中的 元组inferextendsParameters等,如果您还不是特别了解,可以先来学习此篇文章 快速入门TypeScript类型别名

工具函数

Head

在上面我们了解到 标准的柯里化一次接受一个参数。因此我们需要创建一个工具函数来获取第一个参数的类型。

const toCurry = (name: string, age: number, gender: 'boy' | 'gril') => true
type ToCurryParameters = Parameters<typeof toCurry>

image.png 通过typescript的内置类型别名 Parameters来获取函数的参数,并返回元组。之后获取元组的第一个元素类型即为函数第一个参数的类型。

type Head<T extends unknown[]> =
    T extends [infer First, ...unknown[]] ? First : never

测试如下:

//
type HeadTest = Head<ToCurryParameters> // string
type HeadTest1 = Head<[1, 2, 3]> // 1

Tail

同时标准的柯里化函数一个个的使用参数,这意味着每次调用之后需要我们获取到剩余的参数类型,以便通过Head<P>,在此获取下次调用需要传递的函数类型。从 TypeScript 3.4 开始,我们不能“简单地”删除元组的第一个条目。因此,我们将使用一个非常有效的技巧来解决这个问题 :

type Tail<T extends any[]> =
    ((...args: T) => any) extends ((arg0: any, ...tail: infer TailResult) => any)
    ? TailResult
    : []

测试如下:

type TailTest = Tail<[1, 2, 3, 4]> //[2, 3, 4]
type TailTest1 = Tail<Tail<['hello', 'world', '!']>> // ['!']

HasTail

一个柯里化函数将一直返回一个函数,直到它的所有参数都被传递完成,因此我们需要判断是否达到一个条件即 Tail 消耗完毕,没有参数可以继续消耗。

type HasTail<T extends unknown[]> =
    T extends ([] | [unknown]) ? false : true

测试如下:

type Test = ['hello', 'world', '!']
type HasTailTest = HasTail<Test> // true
type HasTailTest1 = HasTail<Tail<Test>> // true
type HasTailTest2 = HasTail<Tail<Tail<Test>>> // false

CurryV0

下面来实现一个经典版本的柯里化函数,即每次调用只能传入一个参数。他需要接受一个元组P 以及一个函数的返回值类型R。当 HasTail<P>false 时,表示所有的参数已经传递完毕,是时候返回原有函数返回值类型R了,如果不为false,则需要返回一个函数,并且返回的函数接受参数的类型为 Head<P>

type CurryV0<P extends unknown[], R> = P extends never
    ? never
    : (arg: Head<P>) => HasTail<P> extends true
        ? CurryV0<Tail<P>, R>
        : R
测试如下:
const toCurryV0 = (name: string, age: number, gender: 'boy' | 'gril') => true
declare function curryV0<P extends unknown[], R>(fn: (...args: P) => R): CurryV0<P, R>
const curriedV0 = curryV0(toCurryV0)
curriedV0('xiaopingbuxiao')(18)('boy')

image.png 此时经典版本的curry已经创建完成,并且类型提示已经适用于无限数量的参数了。 注意:**CurryV0**** 中的 ****P extends never ? never**并无实际意义,只是为了强制**typescript**进行类型推断。如果没有这一段代码,则会是下面的结果: image.png

CurryV1

上面我们已经实现了柯里化函数的一个经典版本,假如我们需要被转换的函数如下: image.png 当我们尝试使用剩余参数(rest parameter)时就会出现错误,这是因为我们限定为了 只接受一个称之为 arg 的单个参数。让我们通过使用 TailPartial来升级函数以便启用剩余参数的写法。

type CurryV1<P extends unknown[], R> =
    P extends never
    ? never
    : (arg: Head<P>, ...rest: Tail<Partial<P>>) => HasTail<P> extends true
        ? CurryV1<Tail<P>, R>
        : R

declare function curryV1<P extends unknown[], R>(fn: (...args: P) => R): CurryV1<P, R>

测试一下:

const toCurryV1 = (name: string, age: number, ...otherParameters: string[]) => true
const curriedV1 = curryV1(toCurryV1)
curriedV1('xiaopingbuxiao', 18, '河南省')

此时看起来可以接受多个参数了,但是我们如下执行时实际上是错误的,但并没有被 typescript 所识别。

curriedV1('xiaopingbuxiao', 18, '河南省')(18, '河南省')

其实有一个很大的设计问题,是我们强制采用了单个参数 arg, 事实上我们需要摆脱单个的 arg 参数,而应该记录一次调用使用的参数数量,更改代码如下

CurryV2

增加一个 泛型 T 来记录我们每次消耗的参数,如下:

type CurryV2<P extends unknown[], R> =
    <T extends unknown[]>(...args: T) => HasTail<P> extends true
        ? CurryV2<Tail<T>, R>
        : R

但是现在,它完全被破坏了,因为没有更多的类型检查,并且我们 Tail 完全是没有作用的,因为上面我们的实现 Tail 并不会排除多个或者0个参数,而是每次都去除了第一个元素,剩余的作为下一次调用的P。因此我们需要更多的工具函数处理。

类型编程中使用递归

Last 示例

花点时间尝试理解这种复杂但非常短的类型。这个 示例将一个元组作为参数,并提取其最后一个条目。

type Last<P extends unknown[]> = {
    0: Last<Tail<P>>,
    1: Head<P>
}[HasTail<P> extends true ? 0 : 1]

type LastResult = Last<[1, 2, 3, 4]> // 4

此示例演示了条件类型在用作索引类型的访问器时的强大功能。一个更直观的解释如下: image.png 这种方式是一种理想的方法,也是一种安全的递归方式,就像我们刚才所做的那样。但它不仅限于递归,它是组织复杂条件类型的一种很好的可视化方式 当前你也可以通过下面的方式来实现一个Last,如果您还不太熟悉typescript的类型编程,下面的方式可能更容易理解

type Last<P extends unknown[]> =
    HasTail<P> extends true
    ? Last<Tail<P>>
    : P extends [infer Result] ? Result : never

type LastResult = Last<[1, 2, 3, 4]> // 4
type LastResult1 = Last<['hello']> // hello

新的工具函数

Length

`CurryV2`中我们说过要记录每次消耗的参数,因为我们很明显需要用到一个获取参数长度的函数,如下:
type Length<T extends unknown[]> = T['length']

测试如下:

type LenghtTest = Length<[1]> //1
type LenghtTest1 = Length<['hello', 'world']> // 2

Prepend

在元组的头部增加新的类型,来做为记录长度。

type Prepend<E, T extends unknown[]> =
    ((head: E, ...args: T) => unknown) extends ((...args: infer U) => unknown)
    ? U
    : T

测试如下:

type PrependTest = Prepend<1, ['hello', 'world']> // [1, "hello", "world"]
type PrependTestLength = Length<PrependTest> // 3
type PrependTest1 = Prepend<string, []> // [head: string]
type PrependTest1Length = Length<PrependTest1> // 1

Drop

因为我们要创建每次可以传入0个或N个参数的Curry函数,因此需要创建一个从元组的头部删除掉N个参数后返回剩余参数的方法。如下:

type Drop<N extends number, T extends unknown[], Temp extends unknown[] = []> = {
    0: Drop<N, Tail<T>, Prepend<any, Temp>>,
    1: T
}[Length<Temp> extends N ? 1 : 0]

测试如下:

type DropTest = Drop<1, [1, 2, 3]> //[2, 3]
type DropTest1 = Drop<2, [1, 2]> //[]

当然你不习惯通过在索引类型中使用条件类型来创建递归,也可以创建Drop如下:

type Drop<N extends number, T extends unknown[], Temp extends unknown[] = []> =
    Length<Temp> extends N
    ? T
    : Drop<N, Tail<T>, [...Temp, unknown]>

通过Drop我们可以知道,还有哪些参数需要被消耗,现在继续修改Curry

CurryV3

继续修改Curry类型函数如下:通过Drop<Length<T>, P>获得剩余的参数,判断剩余的参数为0时返回R,否则就继续递归。

type CurryV3<P extends unknown[], R> =
    <T extends unknown[]>(...args: T) => Length<Drop<Length<T>, P>> extends 0
        ? R
        : CurryV3<Drop<Length<T>, P>, R>

image.png 但是此时却出现了错误,我们非常确定当前的Drop<Length<T>, P>的返回类型为 unknown[],但是typescript的推断却出现了问题,这是因为typescript的推断的过程中只有被用到是才会进行推断。我们有两种方式处理.

第一种方式

强制typescript进行推断,更改Drop类型函数如下

type Drop<N extends number, T extends unknown[], Temp extends unknown[] = []> =
    N extends never
    ? never :
    {
        0: Drop<N, Tail<T>, Prepend<any, Temp>>,
        1: T
    }[Length<Temp> extends N ? 1 : 0]

第二种方式

创建一个Cast类型函数

type Cast<X, Y> = X extends Y ? X : Y

同时修改CurryV3

type CurryV3<P extends unknown[], R> =
    <T extends unknown[]>(...args: T) => Length<Cast<Drop<Length<T>, P>, unknown[]>> extends 0
        ? R
        : CurryV3<Cast<Drop<Length<T>, P>, unknown[]>, R>

注意

由于我们创建了一个新的类型T来保存调用的参数,并且T的类型限定为unknown[]。这将会导致我们所有的参数类型丢失。因此需要通过 Cast限定T的类型为Partial<P>。继续修改CurryV3如下:

type CurryV3<P extends unknown[], R> = P extends never
    ? never
    : <T extends unknown[]>(...args: Cast<T, Partial<P>>) => Length<Cast<Drop<Length<T>, P>, any[]>> extends 0
        ? R
        : CurryV3<Cast<Drop<Length<T>, P>, any[]>, R>

现在再来测试一下**,**看起来已经符合我们的进阶需求,可以一次接受多个参数,也可以一次接受一个参数。

declare function curryV3<P extends unknown[], R>(fn: (...args: P) => R): CurryV3<P, R>
const toCurryV3 = (name: string, age: number, gender: 'gril' | 'boy') => true
const curriedV3 = curryV3(toCurryV3)
curriedV3('xiaopingbuxiao', 18)('gril') // true
curriedV3('xiaopingbuxiao')(18)('gril') // true

CurryV4

通过CurryV3版本我们看起来已经符合我们的使用,但是它依然是不支持剩余参数的,因为其实剩余参数是无限的,typescript的最佳推断是我们的元组lengthnumber

type RestType = [string, number, ...string[]]
type RestTypeTest = Length<RestType> // number

这将会导致我们不能使用Length来处理剩余参数。因此更完善的做法需要更改递归结束的判断条件。如下:

type CurryV4<P extends unknown[], R> =
    P extends never
    ? never
    : <T extends unknown[]>(...args: Cast<T, Partial<P>>) =>
        Drop<Length<T>, P> extends [unknown, ...unknown[]]
        ? CurryV4<Drop<Length<T>, P>, R>
        : R

测试一下:

declare function curryV4<P extends unknown[], R>(fn: (...args: P) => R): CurryV4<P, R>
const toCurryV4 = (name: string, age: number, gender: 'gril' | 'boy', ...extra: string[]) => true
const curriedV4 = curryV4(toCurryV4)
curriedV4('xiaopingbuxiao', 18)('gril', 'hello', 'world') // true

现在你已经得到了一个完善的Curry函数类型,它可以一次接受一个函数或者多个函数,但是,我们可以让他变得更棒。

Placeholders

在开始本段之前,希望你对函数是编程的partial(偏应用)有一定的了解,你可以单击这里 函数式编程偏应用 有一个初步的了解。当然如果你不了解,问题也不大🙈。 现在我们要实现一个可以通过占位符占位的函数类型。函数的参数将允许接受占位符,之后再真正的传递参数进行调用。如下他们调用时是等价的

fn(1,2,3)
fn(_,2,3)(1)
fn(_,_,3)(1)(2)
fn(_,2,_)(1,3)
fn(_,2)(1)(3)

现在我们继续创建一些工具函数。

工具函数

Pos

查询元组的最后一个位置

type Pos<I extends unknown[]> = Length<I>

Next +1

type Next<I extends unknown[]> = Prepend<unknown, I>

Prev -1

type Prev<I extends unknown[]> = Tail<I>

测试如下:

type IteratorTest = [string, number]
type IteratorTest1 = Pos<IteratorTest> // 2

type IteratorTest2 = Pos<Next<IteratorTest>> // 3

type IteratorTest3 = Pos<Prev<IteratorTest>> // 1

Iterator

创建一个迭代器

type IteratorTs<Index extends number = 0, From extends unknown[] = [], Temp extends unknown[] = []> = {
    0: IteratorTs<Index, Next<From>, Next<Temp>>
    1: From
}[
    Pos<Temp> extends Index ? 1 : 0
]

或者下面的方式

type IteratorTs<Index extends number = 0, From extends unknown[] = [], Temp extends unknown[] = []> =
    Pos<Temp> extends Index ? From : IteratorTs<Index, Next<From>, Next<Temp>>

测试一下:


type IteratorTsTest = IteratorTs<2> //[head: unknown, head: unknown]
type IteratorTsTest1 = IteratorTs<3, IteratorTsTest> //[head: unknown, head: unknown, head: unknown, head: unknown, head: unknown]

Reverse

type Reverse<T extends unknown[], R extends unknown[] = [], Temp extends unknown[] = []> = {
    0: Reverse<T, Prepend<T[Pos<Temp>], R>, Next<Temp>>
    1: R
}[
    Pos<Temp> extends Length<T> ? 1 : 0
]

或者下方的方式

type Reverse<T extends unknown[], R extends unknown[] = [], Temp extends unknown[] = []> =
    Pos<Temp> extends Length<T> ? R : Reverse<T, Prepend<T[Pos<Temp>], R>, Next<Temp>>

测试一下:

type ReverseTest = Reverse<[1, 2, 3, 4]> // [head: 4, head: 3, head: 2, head: 1]

Concat


type Concat<T extends unknown[], U extends unknown[]> = [...T, ...U
                                                         
// 测试一下
type ConcatTest = Concat<[1, 2], [3, 4]> // [1, 2, 3, 4]

Append

type Append<E, T extends unknown[]> = [...T, E]

//测试一下
type AppendTest = Append<3, [1, 2]> //[1, 2, 3]

占位分析

每当我们进行占位符作为参数传递时,我们都需要进行分析,进而能够判断出哪些参数是被跳过。

GapOf

用来检查占位符在元组中的位置(从第I个位置开始计算),如果找到,再将匹配到的类型收集新的元素TN

namespace R {
    export interface __ {
        '@@functional/placeholder': true
    }
}

type GapOf<T1 extends unknown[], T2 extends unknown[], TN extends unknown[], I extends unknown[]> =
    T1[Pos<I>] extends R.__
    ? Append<T2[Pos<I>], TN>
    : TN

测试一下:

type GapOfTest = GapOf<[R.__, R.__], [number, string], [], IteratorTs<0>> //[number]
type GapOfTest1 = GapOf<[R.__, 'a'], [number, string], [], IteratorTs<1>> //[]

解释一下,GapOfTest从第0个位置开始,后面是否有__的占位符,如有有,它对应的类型时什么。GapOfTest1从第1个位置开始,后面是否有__的占位符。并将第一个占位符对应的类型返回回来。

GapsOf

将所有占位符对应位置的类型收集起来并返回。

type GapsOf<T1 extends unknown[], T2 extends unknown[], TN extends unknown[] = [], I extends unknown[] = []> = {
    0: GapsOf<T1, T2, GapOf<T1, T2, TN, I>, Next<I>>
    1: Concat<TN, Cast<Drop<Pos<I>, T2>, unknown[]>>
}[
    Pos<I> extends Length<T1> ? 1 : 0
]

或者:

type GapsOf<T1 extends unknown[], T2 extends unknown[], TN extends unknown[] = [], I extends unknown[] = []> =
    Pos<I> extends Length<T1> ? Concat<TN, Cast<Drop<Pos<I>, T2>, unknown[]>>
    : GapsOf<T1, T2, GapOf<T1, T2, TN, I>, Next<I>>

这里比较复杂,解释一下。T1会为已经传入的参数(可能未传递完毕或者存在占位符),T2为函数参数构成的元组。递归将所有占位符对应的位置,以及还未传入的参数的类型作为元组返回。 image.png 看一下测试的上面测试的🌰:当前GapsOfTest返回的类型中,number、string 为两个占位符对应的参数类型,最后一个**symbol**类型为未传递的参数类型

PartialGaps

现在我们将更改函数的参数类型,使其允许占位符__作为参数传递。

type PartialGaps<T extends unknown[]> = {
    [K in keyof T]?: T[K] | R.__
}

测试一下:

type PartialGapsTest = PartialGaps<[number, object]>
// [(number | R.__ | undefined)?, (object | R.__ | undefined)?]

此时参数的传递变为了可以接受undefined 我们只是希望参数变为 [(number | R.__ )?, (object | R.__ )?]。因此继续处理参数。


type CleanedGaps<T extends unknown[]> = {
    [K in keyof T]: NonNullable<T[K]>
}
type Gaps<T extends unknown[]> = CleanedGaps<PartialGaps<T>>

测试一下:

type GapsTest = Gaps<[number, object]> //  [(number | R.__)?, (object | R.__)?]

此时Gaps已经满足需求,并且在后期的参数限定中,不再通过Partial来限定接受到的参数。

Curry

type Curry<P extends unknown[], R> =
    <T extends unknown[]>(...args: Cast<T, Gaps<P>>) =>
        GapsOf<T, P> extends [unknown, ...unknown[]]
        ? Curry<GapsOf<T, P>, R>
        : R

declare function curry<P extends unknown[], R>(fn: (...args: P) => R): Curry<P, R>

测试如下:

const toCurry = (name: string, age: number, gender: 'boy' | 'gril') => true
const curried = curry(toCurry)
curried('xiaopingbuxiao')(18)('boy')
curried('xiaopingbuxiao', 18, 'boy')
const placeholder: R.__ = { '@@functional/placeholder': true }
curried(placeholder)('xiaopingbuxiao')(18)('boy')
curried(placeholder)('xiaopingbuxiao')(placeholder)(18)('boy')


const toCurry1 = (name: string, age: number, ...otherParameters: string[]) => true
const curried1 = curry(toCurry)
curried1('xiaopingbuxiao')(18,'boy','河南省')

文中用到所有代码 点击这里

参考文章 Learn Advanced TypeScript

推荐小册 TypeScript 类型体操通关秘籍