Variadic functions, such as the common zip
function on arrays, are convenient and remove the need for lots of specific arity-function variants, e.g., zip2
, zip3
, zip4
, etc. However, they can be difficult and tedious to type correctly in TypeScript when the return type depends on the parameter types, and the parameter types are heterogeneous.
Given a typical zip
on arrays:
const a: number[] = [1, 2, 3]
const b: string[] = ['foo', 'bar']
const c: boolean[] = [true, false]
const result = zip(a, b, c) // [[1, 'foo', true], [2, 'bar', false]]
Ideally, we’d like the inferred type of result
to be ReadonlyArray<[number, string, boolean]>
. Further, we’d like this to work for the general case, that is, with no artificial limitations on the number of parameters zip
can accept while retaining type information.
The most common approach in TypeScript is to write multiple type variations for several parameters you guess is beyond what any reasonable use case might ever need, and then possibly a final homogeneously-typed variant, or weakly-typed catchall using any
. For example:
function zip<A, B>(a: ReadonlyArray<A>, b: ReadonlyArray<B>): ReadonlyArray<[A, B]>;
function zip<A, B, C>(a: ReadonlyArray<A>, b: ReadonlyArray<B>, c: ReadonlyArray<C>): ReadonlyArray<[A, B, C]>;
function zip<A, B, C, D>(a: ReadonlyArray<A>, b: ReadonlyArray<B>, c: ReadonlyArray<C>, d: ReadonlyArray<D>): ReadonlyArray<[A, B, C, D]>;
function zip<A, B, C, D, E>(a: ReadonlyArray<A>, b: ReadonlyArray<B>, c: ReadonlyArray<C>, d: ReadonlyArray<D>, e: ReadonlyArray<E>): ReadonlyArray<[A, B, C, D, E]>;
// ...
// ... and so on, argh!
// ...
// ... finally, we have to use any for the implementation, yuck!
function zip(...arrays: ReadonlyArray<any>): ReadonlyArray<any> {
// ...
}
Many popular libraries such as RxJS, lodash, and @most/core use the technique in the types. As a maintainer of @most/core
, I’ve used this technique myself.
Although it works, the technique has some problems:
- It’s tedious to read and write, requiring a lot of repetition, which can lead to mistakes.
- Adding new higher arities (e.g., to accommodate new use cases) requires adding more code.
- Trying to call the function with more parameters than there are declared variants causes:
- a type error when a weakly-typed catchall variant hasn’t been declared, or
- loss of type information when one has.
Ideally, we’d like a technique that:
- is less tedious to read and write and minimizes repetition;
- scales to any arity without requiring code to be added per-arity; and
- retains full type information for all arities.
TypeScript correctly infers heterogeneous rest parameter types as tuples. We can take advantage of that, along with some relatively simple type-level programming, in the form of mapped and conditional types, to craft a better technique. Besides, the technique is general and can be applied to any heterogeneous variadic function, including higher-order heterogenous variadic functions, e.g., zipWith
.
First, let’s look at the technique in the context of zip
. Just as the variadic zip
function maps a tuple of arrays to an array of tuples, we need a type-level analog that maps the type of tuple of arrays to the type of array of tuples. We can use TypeScript’s mapped and conditional types:
// A type-level Zip, represented in TS as a mapped type, that maps a tuple of Arrays to the
// associated zipped type.
// For example, it maps types: [Array<number>, Array<string>] -> [number, string].
type Zip<A extends ReadonlyArray<any>> = {
[K in keyof A]: A[K] extends ReadonlyArray<infer T> ? T : never
}
We can use this type-level Zip
“function” to map the value-level zip
function’s argument list type (a tuple type inferred by TypeScript) to its corresponding, strong return type:
// Note the return type.
//
// The value-level zip function maps input values to output values.
// The type-level Zip function maps input types to output types.
function zip<Arrays extends ReadonlyArray<any>[]>(...arrays: Arrays): ReadonlyArray<Zip<Arrays>> {
const len = Math.min(...arrays.map(a => a.length))
// TS needs a hint or it infers zipped as any[].
const zipped: Zip<Arrays>[] = new Array(len)
for (let i = 0; i < len; i++) {
// TS needs a hint to know that the map result is of the right type,
zipped[i] = arrays.map(a => a[i]) as Zip<Arrays>
}
return zipped
}
The important bits:
- The parameters use TypeScript’s tuple inference:
Arrays
captures the specific type of each array inzip
’s argument list. - The return type uses the
Zip
type-level function to map the strongly-typedArrays
tuple to the corresponding strongly-typedzip
result type.
Here’s a TypeScript playground of the zip example. We indeed find that the type of result
is inferred as ReadonlyArray<[number, string, boolean]>
(which the TypeScript playground displays as readonly [number, string, boolean][]
when hovering over result
), as we originally wanted. Feel free to add more arrays to the zip
call at the bottom to see how it affects the result
’s inferred type.
The technique has the characteristics we set out to achieve:
- It's less repetitive, and less code overall to write, both of which help reduce mistakes. While readability is subjective, I believe the reduced verbosity and repetition make it easier to read.
- It scales to any arity without adding code per arity.
- It retains complete type information for all arities.
The same basic pattern can be applied any heterogeneous variadic function whose return type depends on its parameter types:
- In the parameter list, use TypeScript’s rest parameters tuple inference.
- In the return type, use TypeScript’s mapped and conditional types to derive the return type from the inferred parameter tuple type.
It would likely be possible to create reusable abstractions for some use cases if TypeScript supported higher kinds or true type-level functions. Variadic kinds may also completely subsume the pattern. It’s not clear when any of those things make their way into a TypeScript release, however.
In part 2 (coming soon!), we’ll look at how to extend this technique to higher-order variadic functions, such as zipWith
.