Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save Frikki/a5bef51c4dbfd059e0d68b872f491e93 to your computer and use it in GitHub Desktop.
Save Frikki/a5bef51c4dbfd059e0d68b872f491e93 to your computer and use it in GitHub Desktop.
A technique for strongly-typed, heterogeneous variadic function types in Typescript

Simpler Variadic Function Types in TypeScript (part 1)

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.

Zip Example

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.

Current Approach

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.

Problems with the Current Approach

Although it works, the technique has some problems:

  1. It’s tedious to read and write, requiring a lot of repetition, which can lead to mistakes.
  2. Adding new higher arities (e.g., to accommodate new use cases) requires adding more code.
  3. 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.

A Simpler Technique

Ideally, we’d like a technique that:

  1. is less tedious to read and write and minimizes repetition;
  2. scales to any arity without requiring code to be added per-arity; and
  3. 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:

  1. The parameters use TypeScript’s tuple inference: Arrays captures the specific type of each array in zip’s argument list.
  2. The return type uses the Zip type-level function to map the strongly-typed Arrays tuple to the corresponding strongly-typed zip 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.

Advantages of the Simpler Technique

The technique has the characteristics we set out to achieve:

  1. 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.
  2. It scales to any arity without adding code per arity.
  3. It retains complete type information for all arities.

Generalizing the Pattern

The same basic pattern can be applied any heterogeneous variadic function whose return type depends on its parameter types:

  1. In the parameter list, use TypeScript’s rest parameters tuple inference.
  2. 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.

What Next?

In part 2 (coming soon!), we’ll look at how to extend this technique to higher-order variadic functions, such as zipWith.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment