Some time ago I drilled into How to master advanced TypeScript patterns this article. This is an earlier blog post by Pierre-Antoine Mills, author of ts-toolbelt. The article raises a challenging topic: How can TS write type support for collinear functions?

I did some practice with the original article, and then seemed to come to some knowledge about TS generics closer to the substance – from the collection perspective. Based on this knowledge, I found that most of the generalizations in the original article are written in a much tighter way. I thought this practice and thought process was worth documenting, hence this paper.

As with the original article, this article does not expand on the issues of currying or functional programming; currying is just material for discussing TS generic development. The clue to this article is the role of each generic type in my complete implementation, but that’s not the point, the point is the insight behind it – in the first half of the article, I often discuss a simple generic type at length, so please don’t skip it.

Propositions

Collocalization is an important concept in the field of functional programming, and represents the process of converting a multi-argument function into a function that “takes one argument at a time”, such as converting f(a,b,c) to f(a)(b)(c). To give a more detailed example.

1
2
3
4
5
6
7
8
9
// toCurry 函数为待柯里化的普通函数
declare const toCurry: (a1: 1, a2: 2, a3: 3, a4: 4) => 0;

// curry 是柯里化转换函数,接收普通函数 toCurry,返回转换后的函数(先用 unknown 类型表示)
declare const curry: (toCurry: Function) => unknown;

// curried 是柯里化转换后的函数,调用者按次序每次传入一个参数,所有参数传入后,得到最终的返回值
const curried = curry(toCurry);
curried(1)(2)(3)(4);            // => 0

In its simplest form, the Currier can take only one argument at a time.

For advanced kriging, an indefinite number of parameters can be taken at a time.

1
2
// 调用 curried 一次传入多个参数
curried(1)(2,3)(4);             // => 0

Even residual parameters and placeholders can be supported.

1
2
3
4
5
6
7
8
9
// toCurry 中包含剩余参数
declare const toCurry: (a1: 1, a2: 2, a3: 3, a4: 4, ...args: 5[]) => 0;
const curried = curry(toCurry);

// 调用 curried 时也可以传入剩余参数
curried(1)(2, 3)(4, 5, 5, 5, 5);    // => 0

// 调用 curried 时通过传入占位符把参数 2 移到了 3 之后
curried(1)(__, 3)(2, 4, 5);     // => 0

The curried conversion function curry is the core of currying. The conversion function takes a normal function toCurry - temporarily denoted by the type Function - and returns the curried function curried (later also called curried function or curried function) - temporarily denoted by the type unknown. - temporarily represented by the type unknown. How to write a legal type expression for it is the main thread of this article.

CurriedV1: The simplest currying

The simplest, one-argument-at-a-time currying is implemented by me as follows.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type Length<T extends unknown[]> = T['length'];

type Head<T extends unknown[]> = T extends [] ? never : T[0];

type Tail<T extends unknown[]> = T extends [] ? never : T extends [unknown, ...infer R] ? R : T;

type CurriedV1<P extends unknown[], R> = P extends [] ? R : (arg: Head<P>) => CurriedV1<Tail<P>, R>;

type Curry = <P extends unknown[], R>(fn: (...args: P) => R) => CurriedV1<P, R>;

declare const curry: Curry;
declare const toCurry: (a1: 1, a2: 2, a3: 3, a4: 4) => 0;

const curried = curry(toCurry);
curried(1)(2)(3)(4); // => 0

Generic Length

The first generic Length returns the length of a tuple.

1
type Length<T extends unknown[]> = T['length'];

The first thing to understand is that a type is a collection of objects . For example, the type number is the set of all numbers, the type 1 represents a single set of elements consisting of the value 1, and the type string[] is the set of all “arrays where every item is a string”.

A generic, formally, is a function of a type (converting one type to another); from a collection perspective, it is a map of a collection (changing one collection to another).

The mapping of a set must be based on rules that act on the elements within the set. Suppose there are sets A and B. We can say that there is a mapping relationship between A and B only if any element in A, according to some rule, can be transformed into an element in B.

Since mapping can only be done from one set to another, how can we understand the case of multiple generic arguments? Answer: Think of multiple generic parameters as a tuple type.

Take Length as an example.

1
2
type Length_Test1 = Length<[unknown]>;          // => 1
type Length_Test2 = Length<unknown[]>;          // => number

Passing the type [unknown] to Length yields the type 1, describing the fact that any of the countless elements belonging to the type [unknown], whether [1] or ['foo'] or [{foo: true}], to which the length is applied, yields the result 1. These elements are transformed into the value 1 by the rule Length; in other words, the set represented by the type [unknown] is mapped by the rule Length to a single-element set containing only one element (that is, the value 1), and the type of this set is the type 1.

Passing the type unknown[] to Length yields the type number, because any one of the countless elements of the type unknown[], whether [] or [1] or ['foo', true], which is given a length, yields 0, 1 or 2, etc. etc., all of type number. Note that Length does not guarantee that the rule-transformed value occupies the mapped set: there is no array with length 0.5 or -1. So the result of the generic operation length<unknown[]>, number, is actually a hyperset of the real-world mapped result set.

It is unavoidable that generics return a superset of the `real result set’, which is often broader than we would expect from a collection. From a collection perspective, the purpose of writing a generic type is to provide a superset of the “true result set” that can be described by type rules and at the same time as small as possible. It is important to understand this.

If JS supported unsigned integer types, Length<unknown[]>, it would seem to get the perfect result set, but this is really just a coincidence. More often than not, it is not possible to get a perfect result set: for example, Length<[unknown, . . unknown[]]> needs to return a set “consisting of integers greater than 1”.

JavaScript: a priori knowledge

How does TS know that the result of Length<unknown[]> is number? Is there some principle between Length<unknown[]> and number that can still be understood? I think: there is no principle left, TS just gives a straightforward answer based on a priori (axiomatic) knowledge.

The type system of TS is tailored to JS: any JS literal is a single element type of TS; JS base types such as number or string also form the base types of TS; with a syntax similar to that used to define arrays, JSON objects, and functions, we can create array types and tuple types, object types, and function types to represent the types that contain the eligible TS is of course familiar with the habitat of all object types in JS - what member properties they have, how they transform into each other, etc. - this knowledge is a priori for TS, so TS can easily and correctly perform operations on base types.

1
2
3
4
// 基础类型间的运算
type T1 = number['toFix'];                      // => () => string
type T2 = [number, string][1];                  // => string
type T3 = keyof { foo: number, bar: string };   // => 'foo' | 'bar'

Generic Head

The second generic Head takes the type of the first element in the tuple T.

1
type Head<T extends unknown[]> = T extends [] ? never : T[0];

Head first determines whether T extends [], if so, T is a single-element set containing only the empty array and returns never; otherwise, T is not a single-element set of the empty array and may have the first element and returns T[0].

Why is there only the extends keyword in the conditional generic, but not the equals keyword or the == operator? My insight is that in set operations, only the inclusion operation can produce a yes or no result, while the other operations on sets: intersection, union, complement, mapping, they all result in another set. In the context of sets, A is contained in B, which means A is a subset of B. Switch to the context of types, which means A is a subclass of B, which means A inherits from B.

How can we tell that two types are identical? Just determine that they contain each other (inherit from each other).

To perform some tests on Head.

1
2
3
4
type Head_Test1 = Head<[1, number]>;    // => 1
type Head_Test2 = Head<string[]>;       // => string
type Head_Test3 = Head<unknown>;        // ts error
type Head_Test4 = Head<[]>;             // => never

The results of the first three tests are intuitive. The fourth one, when we pass a single-element set containing the empty array to Head, yields a result of type never, indicating the empty set, and there is nothing wrong with that either.

Let’s take another look at the second test: is the empty array an element of the string[] set, please? Of course it is. So, in a real-world Head mapping, to what elements is the empty array mapped?

In set theory, the premise of a mapping is that the mapping rules are valid for all elements in the source set. How should we understand Head?

My understanding is that there is a never object in TS that can’t be written out (not in JS), and a never type that can be written out represents a single-element set containing a never object. Also, any type that can be written out in TS implicitly contains never objects, which makes any type that merges with a never type get itself, thus making the otherwise single-element set of never types on concept the empty set.

From this perspective, Head<string[]> makes sense: the empty array elements in the string[] set are mapped to never, while the other elements are mapped to string; since string | never is still string, it eventually returns string.

Generic Tail

The third generic Tail extracts the type of the tail items of the tuple T (i.e., those remaining after the first item).

1
type Tail<T extends unknown[]> = T extends [] ? never : T extends [unknown, ...infer R] ? R : T;

It’s a bit complicated.

Let’s start with a short version.

1
type SimpleTail<T extends unknown[]> = T extends [unknown, ...infer R] ? R : never;

SimpleTail is formally very similar to JS code: it uses the residual argument operator to extract the remainder of the tuple excluding the first item. A simple test is no problem either.

1
2
3
type SimpleTail_Test1 = SimpleTail<[]>;                    // => never
type SimpleTail_Test2 = SimpleTail<[1, 2, string]>;        // => [2, string]
type SimpleTail_Test3 = SimpleTail<[1, 2, ...string[]]>;   // => [2, ...string[]]

However, if you test it with string[], you get never. That’s not quite right.

1
type SimpleTail_Test3 = SimpleTail<string[]>;              // => never

In the real world, almost all elements of a string[] set (except for the empty array object) are meaningful for the tail operation. In fact, if we generalize by giving some examples, we must conclude that the result of taking the last term of string[] is string[]. However, according to the implementation of SimpleTail: string[] is indeed not [unknown, . . unknown[]], we can only return never.

Let’s look at the formal version of Tail.

1
type Tail<T extends unknown[]> = T extends [] ? never : T extends [unknown, ...infer R] ? R : T;
  1. Branch 1: If T is a subset of the empty array single-element set, we can conclude that T can only be the empty array single-element set or never, which returns never.
  2. Branch 2: If T is a subset of “the set consisting of all “arrays with the first item””, we can conclude that T cannot contain empty array elements, and extract the type of the last item in a form similar to that in SimpleTail.
  3. Branch 3: If neither of the above is satisfied, we return T directly.

Passing string[] to test this, we get the expected type: string[] by branching 3.

1
type Tail_Test4 = Tail<string[]>;                           // => string[]

Are you really sure? If T does not satisfy branch 1 or branch 2, it must be a pure array type like string[] or number[], and not anything else?

Let’s summarize the writing of array (including tuple) types: (we don’t care about the specific types of array items, we use unknown instead)

  1. empty arrays: [].
  2. pure arrays: unknown[]; 3.
  3. tuple: [unknownA, unknownB, unknownC].
  4. a tuple with remaining terms: [unknownA, unknownB, . . unknownC[]] .

After summarizing, we find that there are only 4 ways to write an array type, nothing else! The array types that can be written and the array types that can be calculated (returned by other generics) can all be grouped together in the end. These 4 ways of writing are the boundaries on how TS handles array types, in other words TS cannot produce array types that “cannot be combined out with these 4 ways of writing”.

Based on this knowledge, we are confident that only the second write method (pure array types) will make it to branch 3, and we can safely return direct T in branch 3.

Note that there is a catch here. Consider the case of passing in a merge set.

1
type Tail_Test5 = Tail<[] | string[] | [1, 2, 3]>;          // string[] | [2, 3]

According to set theory, the mapping of a concatenation should be done by mapping each of the individual sets that make up the concatenation, separately, and then taking the concatenation of multiple result sets as the final result.

Tail does not disappoint us, it returns the expected type correctly. But this is conditional, the branch condition in the generic must satisfy the constraint Distribution condition type: i.e. the condition must be a generic argument directly extends some type (in the form T extends SOMETYPE), if we replace the first condition T extends [] in the Tail implementation with Length<T> extends 0, the constraint on the type of the distribution condition fails and the proposition “T can only be one of these 4 ways” ceases to exist, and the building collapses.

1
2
3
type BrokenTail<T extends unknown[]> = Length<T> extends 0 ? never : T extends [unknown, ...infer R] ? R : T;

type BrokenTail_Test6 = BrokenTail<[] | [1, 3] | string[]>;  // => [] | [3] | string[]

Have you experienced a certain clumsiness of generic programming? The rules of set mapping (i.e., the semantics of generics) are based on elements within sets, but implementers of generics must answer the question “what set is after the mapping” based on the operations of the set itself. This needs to be summarized practically from a real-world perspective to guarantee the correctness and minimality of the mapping.

Types of conversion functions

Currently, the type of the curry conversion function curry is defined as follows: takes an arbitrary function and returns unknown.

1
2
3
type Curry = (toCurry: Function) => unknown;

declare const curry: Curry;

We want to replace unknown with a more refined type so that the user can get the correct type hint when using the result returned by curry (i.e. the curry function).

Obviously, exactly what this “more refined type” is depends on the function that is passed in when calling curry. We use generic constraints to extract the argument P and the return type R from the passed-in function.

1
type Curry = <P extends unknown[], R>(toCurry: (...args: P) => R) => Curried<P, R>;

Then, pass P and R to the Curried generic type as the type of the curried function (i.e., the aforementioned “more refined type”).

Note that Curry is not a generic mapping, but simply a function type with generic constraints.

Generic type CurriedV1

CurriedV1 is the first version of the implementation of the Curried generic type, which supports the simplest form of currying (consuming one argument at a time).

1
type CurriedV1<P extends unknown[], R> = P extends [] ? R : (arg: Head<P>) => CurriedV1<Tail<P>, R>;

A generic type can be called recursively, and CurriedV1 is such that each time it calls itself recursively, the size of the tuple P is reduced by one until it becomes an empty array, ending the recursion.

Test it out, it’s perfect.

1
type CurriedV1_Test1 = CurriedV1<[1, 2, 3], 0>; // => (arg: 1) => (arg: 2) => (arg: 3) => 0

You may ask: If you pass in an array type of infinite (unknown) length, like number[], will TS get stuck in a dead loop? Let’s try.

1
type CurriedV1_Test2 = CurriedV1<number[], 0>; // => (arg: number) => ...

Instead of reporting an error or getting stuck in a dead loop, TS still maps a function type that can be called ad infinitum. So, we can conclude that progressive scaling down on recursion is not a necessary condition for generic recursion.

In fact, some inert mechanism of generics allows us to define types for things like loop reference objects in JS or functions that return themselves.

1
2
3
4
5
6
7
type Foo<T> = { foo: Foo<T> };
declare const foo: Foo<number>;
foo.foo.foo.foo.foo.foo;            // => 属性 foo 可以无限取下去

type Bar<T extends () => number> = () => Bar<T>;
declare const bar: Bar<() => 1>;
bar()()()()();                      // => 函数 bar 可以无限调用下去

At this point, most of the “generalization from a collection perspective” insights have been stated. Next, I’ll speed things up a bit and finish up with the more advanced implementation of curried types.

CurriedV2: Allowing Indefinite Arguments

It would be easier to use if curried functions could take indefinite arguments (in the form of curried(1)(2,3)(4)). My implementation is.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
type Prepend<E, T extends unknown[]> = [E, ...T];

type Drop<N extends number, P extends unknown[], T extends unknown[] = []> =
    Length<T> extends N ? P : Drop<N, Tail<P>, Prepend<unknown, T>>;

type PartialTuple<T extends unknown[]> = Partial<T> & unknown[];

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

type Curry = <P extends unknown[], R>(fn: (...args: P) => R) => CurriedV2<P, R>;

declare const curry: Curry;
declare const toCurry: (a1: 1, a2: 2, a3: 3, a4: 4) => 0;

const curried = curry(toCurry);
curried(1, 2)(3, 4); // => 0

Generic Prepend

The generic Prepend prefixes the tuple type with an item.

1
2
3
4
5
type Prepend<E, T extends unknown[]> = [E, ...T];

type Prepend_Test1 = Prepend<1, [2]>;                   // ==> [1, 2]
type Prepend_Test2 = Prepend<1, [2, ...3[]]>;           // ==> [1, 2, ...3[]]
type Prepend_Test3 = Prepend<1 | 2, 3[]>;               // ==> [1 | 2, ...3[]]

Note that Prepend is not a conditional type and naturally does not satisfy the distribution condition type, so Prepend_Test3 is [1 | 2, . . 3[]] instead of [1, . . 3[]] | [2, . . 3[]] . If you want the latter, you can put the implementation of Prepend inside the conditional type, as follows.

1
2
3
type DistributedPrepend<E extends unknown, T extends> = E extends unknown ? [E, ...T] : never;

type DistributedPrepend_Test1 = DistributedPrepend<1 | 2, 3[]>;    // ==> [1, ...3[]] | [2, ...3[]]

The subsequent discussion in this article assumes that all incoming types are non-discrete (i.e., non-concurrent in form) and does not discuss the issue of distributing conditional types.

The generic type Drop

The generic type Drop is responsible for deleting the N elements of the header from the tuple. Drop is also recursive, deleting one element at a time recursively, while placing an unknown into tuple T. When the length of the tuple T is equal to N, enough elements have been removed to return the remaining elements.

1
2
type Drop<N extends number, P extends unknown[], T extends unknown[] = []> =
    Length<T> extends N ? P : Drop<N, Tail<P>, Prepend<unknown, T>>;

Simply tested with no problems.

1
2
3
type Drop_Test1 = Drop<2, [1, 2, 3, 4]>;    // => [3, 4]
type Drop_Test2 = Drop<5, [1, 2, 3, 4]>;    // => never
type Drop_Test3 = Drop<5, [1, 2, ...3[]]>;  // => 3[]

The key to Drop is that an empty array, the third generic parameter T, is used for counting.

Interestingly, a similar mechanism can be used to add and subtract integers.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type FromLength<N extends number, P extends unknown[] = []> = 
    Length<P> extends N ? P : FromLength<N, Prepend<unknown, P>>;

type Add<A extends number, B extends number, Res extends unknown[] = FromLength<A>, Count extends unknown[] = []> = 
    Length<Count> extends B ? Length<Res> : Add<A, B, Prepend<unknown, Res>, Prepend<unknown, Count>>;

type Sub<A extends number, B extends number, Res extends unknown[] = [], Count extends unknown[] = FromLength<B>> = 
    Length<Count> extends A ? Length<Res> : Sub<A, B, Prepend<unknown, Res>, Prepend<unknown, Count>>;

type Eight = Add<3, 5>;     // => 8
type Four = Sub<9, 5>;      // => 4

The generic PartialTuple

The story of the generic PartialTuple starts with the official TS generic Partial. We know that the Partial generic can make all properties of an object type optional. When applied to an array, it makes each item of the array optional, e.g. Partial<[number, string]> can be of type similar to [number?, string?].

We expect CurriedV2 to support indefinite arguments, so we need to extract the “first arbitrary term of a tuple” type from the tuple of indefinite arguments: for example, if the indefinite arguments are of type [1, 2, 3], then the indefinite arguments can be [1], [1, 2] or [1, 2, 3]. However, the current type operations of TS do not allow for such a mapping rule as “first arbitrary term of a tuple”, and Partial is the closest implementation (minimal superset).

Why do we need PartialTuple again? Because the type converted by Partial is no longer a tuple: properties like length, map, etc. become optional, which makes objects like {0: 'Hello'} also in the set of Partial<[string]>. The PartialTuple excludes the elements that are not part of the tuple.

1
type PartialTuple<T extends unknown[]> = Partial<T> & unknown[];

The original uses Partial directly without reporting an error, which is a bug in TS: for Partial after passing in a tuple type, it is inconsistent in determining whether it is still a tuple or not under different conditions. I submitted issue and Minimal Replication.

Pan type CurriedV2

CurriedV2 is somewhat similar to CurriedV1 in terms of framework.

1
2
3
4
5
type CurriedV1<P extends unknown[], R> = 
    P extends [] ? R : (arg: Head<P>) => CurriedV1<Tail<P>, R>;

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

The most important difference is that CurriedV2 introduces a generic constraint for the Curried function, so that each time it is called, the number of incoming arguments can be dynamically extracted and the type that should be returned from this call can be calculated accordingly.

1
2
3
4
type CurriedV1_Test1 = CurriedV1<[1, 2, 3], 0>; // => (arg: 1) => (arg: 2) => (arg: 3) => 0

type CurriedV2_Test1 = CurriedV2<[1, 2, 3], 0>;
// => <T extends PartialTuple<[1, 2, 3]>>(...args: T) => CurriedV2<Drop<Length<T>, [1, 2, 3], []>, 0>

As a simple test, we found that CurriedV2_Test1 cannot give the type of the Curried function straightforwardly, because the type is obtained after each step of the call and can only be determined (based on the parameters) at the time of the call.

CurriedV3: support for remaining arguments

Some functions split their arguments into two parts: fixed arguments and residual arguments. For example, toCurry: after the first four fixed parameters, you can pass in any residual parameter of type 5.

1
2
3
4
declare const toCurry: (a1: 1, a2: 2, a3: 3, a4: 4, ...args: 5[]) => 0;

// 必须在最后一次调用时一次性传入所有剩余参数
curry(toCurry)(1, 2, 3)(4, 5, 5);

It would undoubtedly be better if Curried could support such functions: that is the goal of CurriedV3. My implementation is.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type CurriedV3<P extends unknown[], R> =
    P extends [unknown, ...unknown[]]
    ? <T extends PartialTuple<P>>(...args: T) => CurryV3<Drop<Length<T>, P>, R>
    : R;

type Curry = <P extends unknown[], R>(fn: (...args: P) => R) => CurriedV3<P, R>;

declare const curry: Curry;
declare const toCurry: (a1: 1, a2: 2, a3: 3, a4: 4, ...args: 5[]) => 0;

const curried = curry(toCurry);
const result = curried(1, 2, 3)(455);

The difference between CurriedV3 and CurriedV2 only is that the recursion ends under different conditions: CurriedV3 determines that P extends [unknown, . . unknown[]] infers that P still contains a fixed term, and continues the recursion; not satisfying this condition means that there are only remaining arguments in P, and ends the recursion.

Thanks to the tight Drop and the Tail behind it – which handles both pure arrays and tuples with remaining items – the recursive part of CurriedV3 is identical to CurriedV2.

1
2
type Drop_Test3 = Drop<5, [1, 2, ...3[]]>;  // => 3[]
type Tail_Test5 = Tail<1[]>;                // => 1[]

If Drop and Tail don’t handle the more marginal aspects of the above well enough (e.g. returning never or [] directly), CurriedV1 and CurriedV2 don’t suffer much, but the implementation of CurriedV3 is not so easy.

CurriedV4: Placeholder support

Placeholders in Curried can help us delay the timing of passing in parameters. For example.

1
2
3
4
5
6
// 普通的调用
curried(1, 2, 3)(4, 5);
// 占位符调用
curried(1, __, 3)(2, 4, 5);
// 甚至
curried(1, __, 3)(__, 4)(2, 5);

This is the goal of CurriedV4. My implementation is.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
type Equal<X, Y> = X extends Y ? Y extends X ? true : false : false;

type Item<T extends unknown[]> = T extends (infer R)[] ? R : never;

type PlaceholderTuple<T extends unknown[], M extends unknown> = { [P in keyof T]?: T[P] | M } & unknown[];

type Reverse<T extends unknown[], R extends unknown[] = []> =
    Equal<Length<T>, number> extends true
    ? Item<T>[]
    : T extends [unknown, ...unknown[]]
    ? Reverse<Tail<T>, Prepend<Head<T>, R>>
    : R;

type Join<P extends unknown[], T extends unknown[]> = 
    P extends [unknown, ...unknown[]] ? Join<Tail<P>, Prepend<Head<P>, T>> : T;

type Concat<P extends unknown[], T extends unknown[]> = Join<Reverse<P>, T>;

type PlaceholderMatched<T extends unknown[], S extends unknown[], M extends unknown, R extends unknown[] = []> =
    T extends [unknown, ...unknown[]]
    ? PlaceholderMatched<Tail<T>, Tail<S>, M, Head<T> extends M ? Prepend<Head<S>, R> : R>
    : Reverse<R>;

type __ = '__';
type CurriedV4<P extends unknown[], R> =
    P extends [unknown, ...unknown[]]
    ? <T extends PlaceholderTuple<P, __>>(...args: T) =>
        CurriedV4<Concat<PlaceholderMatched<T, P, __>, Drop<Length<T>, P>>, R>
    : R;

type Curry = <P extends unknown[], R>(fn: (...args: P) => R) => CurriedV4<P, R>;

declare const curry: Curry;
declare const toCurry: (a1: 1, a2: 2, a3: 3, a4: 4, ...args: 5[]) => 0;
declare const __: __;

const curried = curry(toCurry);
curried(1, __, 3)(2, 4, 5, 5);          // => 0
curried(1, __, 3)(__, 4)(2);            // => 0

Generic Equal

The generic Equal determines whether two types are exactly equal (note that it is still a set operation, true and false denote a single-element set containing a Boolean value).

1
2
3
4
type Equal<X, Y> = X extends Y ? Y extends X ? true : false : false;

type Equal_Test1 = Equal<number, 1>;            // => false
type Equal_Test2 = Equal<number, number>;       // => true

Generic Item

The generic Item extracts the possible types of array items from the array type.

1
2
3
4
type Item<T extends unknown[]> = T extends (infer R)[] ? R : never;

type Item_Test1 = Item<string[]>; // => string
type Item_Test2 = Item<[string, ...1[]]>; // => string | 1

The generic PlaceholderTuple

The generic PlaceholderTuple is very similar to PartialTuple in that it not only makes each item of the tuple optional, but also makes each item of the incoming type M possible.

1
type PlaceholderTuple<T extends unknown[], M extends unknown> = { [P in keyof T]?: T[P] | M } & unknown[];

Generic Reverse

The generic Reverse flips the head and tail of a tuple.

1
2
3
4
5
6
type Reverse<T extends unknown[], R extends unknown[] = []> =
    Equal<Length<T>, number> extends true
    ? Item<T>[]
    : T extends [unknown, ...unknown[]]
    ? Reverse<Tail<T>, Prepend<Head<T>, R>>
    : R;

The generic Reverse deserves a little expansion. Let’s look at the core part first (starting with T extends): take the array type T, call itself recursively, and each time recursively take the head element of T and push it from the head into R. When T is exhausted, R is naturally the flipped array.

For fixed-length tuple types, this is fine. But what if you want to flip an array type that is not of fixed length?

By simple induction in the real world, we know that Reverse<string[]> should be string[] and the mapping is still perfect; for Reverse<[string, . . number[]]>, we can only map it to Array<string | number> – as we said before, generics often return broader types than we expect, and this is unavoidable.

The first two lines of the Reverse implementation (the non-core part) are used to handle the above two types of irregular-length arrays.

To test.

1
2
3
type Reverse_Test1 = Reverse<[1, 2, 3]>;                // => [3, 2, 1]
type Reverse_Test2 = Reverse<unknown[]>;                // => unknown[]
type Reverse_Test3 = Reverse<[string, ...number[]]>;    // => Array<string | number>

Generic Join

The generic Join joins two tuple types “head-to-head”. Note that the first argument must be the tuple type of the fixed item.

1
2
3
4
5
type Join<P extends unknown[], T extends unknown[]> = P extends [unknown, ...unknown[]] ? Join<Tail<P>, Prepend<Head<P>, T>> : T;

type Join_Test1 = Join<[1, 2], [3, 4]>;         // => [2, 1, 3, 4]
type Join_Test2 = Join<[1, 2], [3, ...4[]]>;    // => [2, 1, 3, ...4[]]
type Join_Test3 = Join<[1, ...2[]], [3, 4]>;    // => ts error

Generic Concat

The generic Concat concatenates two tuple types in order. Similarly, the first argument must be the tuple type of the fixed item.

1
2
3
4
5
type Concat<P extends unknown[], T extends unknown[]> = Join<Reverse<P>, T>;

type Concat_Test1 = Concat<[1, 2], [3, 4]>;         // => [1, 2, 3, 4]
type Concat_Test2 = Concat<[1, 2], [3, ...4[]]>;    // => [1, 2, 3, ...4[]]
type Concat_Test3 = Concat<[1, ...2[]], [3, 4]>;    // => ts error

Generic PlaceholderMatched

The generic PlaceholderMatched finds the items of type M in the tuple T, then extracts the items of the corresponding position from the tuple S, stores them in a new tuple R in order, and finally returns them.

1
2
3
4
type PlaceholderMatched<T extends unknown[], S extends unknown[], M extends unknown, R extends unknown[] = []> =
    T extends [unknown, ...unknown[]]
    ? PlaceholderMatched<Tail<T>, Tail<S>, M, Head<T> extends M ? Prepend<Head<S>, R> : R>
    : Reverse<R>;

A bit of a mouthful. A quick look at the test shows exactly what PlaceholderMatched does.

1
2
type __ = '__';
type PlaceholderMatched_Test1 = PlaceholderMatched<[1, __, __, 4], [1, 2, 3, 4, 5], __>; // => [2, 3]

The generic type CurriedV4

Finally, the full body of the Curried function type, CurriedV4.

1
2
3
4
5
6
7
type __ = '__';

type CurriedV4<P extends unknown[], R> =
    P extends [unknown, ...unknown[]]
    ? <T extends PlaceholderTuple<P, __>>(...args: T) =>
        CurriedV4<Concat<PlaceholderMatched<T, P, __>, Drop<Length<T>, P>>, R>
    : R;

The difference between CurriedV4 and CurriedV3 is in the recursive part. We use PlaceholderTuple<P, __> to constrain the entry of the curried function so that the caller can pass in the placeholder constant __.

In a single recursion, we extract the tuple type consisting of the PlaceholderMatched<T, P, __> and concatenate it with the remaining arguments after this call consumes them (i.e. Drop<Length<T>, P>>) as the new argument P to be passed into the next recursion.

Test it out, perfect.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type Curry = <P extends unknown[], R>(fn: (...args: P) => R) => CurriedV4<P, R>;

declare const curry: Curry;
declare const toCurry: (a1: 1, a2: 2, a3: 3, a4: 4, ...args: 5[]) => 0;
declare const __: __;

const curried = curry(toCurry);

curried(1, __, 3)(2, 4, 5, 5);          // => 0
// => CurriedV4<[1, 2, 3, 4, ...5[]], 0> => CurriedV4<[2, 4, ...5[]], 0>

curried(1, __, 3)(__, 4)(2);            // => 0
// => CurriedV4<[1, 2, 3, 4, ...5[]], 0> => CurriedV4<[2, 4, ...5[]], 0> => CurriedV4<[2, ...5[]], 0>

Summary

Although the discussion of collections in this article has focused on the first half, what prompted me to think about it was actually the practice of a few more advanced scenarios that followed. I found that it seemed clearer to apply the insights from these practices to the very first few simple generic types to make a statement.

In the original article, the base generic at the beginning is not very tight, for example, the Head generic looks like this.

1
type Head<T extends any[]> = T extends [any, ...any[]] ? T[0] : never;

This results in Head<string[]> returning never, which is clearly not what it looks like from a collection perspective.

Many of the base types in the original text had edge use cases that were not handled properly, so as the problem became more complex, the generic implementation became less and less controllable. Later the original authors started introducing Cast generic types to force back inaccurate types that were derived to the edge.

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

This led me to think about what these basic generic types should be implemented as. In repeated practice, I found that the code written by intuition was often not accurate enough, and for a moment, I realized that what I was missing was a set perspective; once I understood the essence of generic operations from a set perspective, I seemed to have a sense of clarity: what I could do and what I could not do, where I could compromise and where I could only give up, I could analyze them all with certainty.