Skip to content

Instantly share code, notes, and snippets.

@briancavalier
Last active October 12, 2023 18:34
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save briancavalier/c4af1538ae9b2e4ab97caf14306625ab to your computer and use it in GitHub Desktop.
Save briancavalier/c4af1538ae9b2e4ab97caf14306625ab 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 2): Higher-order Variadic Functions

In part 1 we looked at a technique that allows expressing strongly-typed variadic functions in a way that solves problems with the current most common approach. It reduces code size and repetition, and it scales to any arity.

I mentioned that the technique can also be extended to higher-order variadic functions, such as the typical zipWith. Let’s explore how to do that.

ZipWith Example

As we did in part 1, let’s start with an example, a typical zipWith on arrays.

function f (a: number, b: string, c: boolean): string {
  return `${a} ${b} ${c}`
}

const a: number[] = [1, 2, 3]
const b: string[] = ['foo', 'bar']
const c: boolean[] = [true, false]

const result = zipWith(f, a, b, c) // ’1 foo true’, ’2 bar false’

In this case, we’d like the type of result to be inferred as ReadonlyArray<string>. That's straightforward. We can simply declare f’s return type using a type variable, e.g. R, and zipWith’s return type as ReadonlyArray<R>.

Notice, however, that zipWith is a higher-order variadic function. That is, it’s a variadic function whose first parameter is also a variadic function. Instead of mapping a tuple of arrays to an array of tuples, zipWith applies its variadic function parameter to each tuple, producing an array of the results.

Thus, instead of the relationship between zipWith’s parameter list type and return type, we need to consider the relationship between zipWith’s parameter list type and f’s parameter list type.

Current Approach

The most common approach is the same one used for zip: enumerate explicit variations up to a point where you can’t imagine someone needing a higher arity.

function zipWith<A, B, R>(f: (a: A, b: B) => R, a: ReadonlyArray<A>, b: ReadonlyArray<B>): ReadonlyArray<R>;
function zipWith<A, B, C, R>(f: (a: A, b: B, c: C) => R, a: ReadonlyArray<A>, b: ReadonlyArray<B>, c: ReadonlyArray<C>): ReadonlyArray<R>;
// ...
// ... and so on, argh!
// ...
// ... finally, we have to use any for the implementation, yuck!
function zipWith<R>(f: (...args: ReadonlyArray<any>) => R, ...arrays: ReadonlyArray<any>): ReadonlyArray<R> {
   // ...
}

Clearly, this has the same issues as we saw with zip. In fact, it's slightly more verbose, since each variant needs to enumerate the types of two variadic parameter lists, rather than one and a tuple return type.

Applying the Simpler Technique

To simplify the type of zip, we relied on TypeScripts parameter list tuple inference and used type-level programming to express the relationship between that inferred tuple type and the return type. However, in the case of zipWith, we need to express the relationship between two parameter lists.

There are two “directions” in which we can apply the technique:

  1. by declaring zipWith’s parameter list type, and deriving f’s from it, or
  2. by declaring f’s parameter list type, and deriving zipWith’s from it.

Let’s look at each.

Declare zipWith, derive f

One important observation we can make about zipWith and f is that the Zip type we used in part 1 already expresses the mapping from zipWith’s parameter list to f’s. Here’s a quick reminder of the Zip type:

// 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
}

In the context of the zipWith example above, the Zip type maps [Array<number>, Array<string>, Array<boolean>] to [number, string, boolean]. Notice that [Array<number>, Array<string>, Array<boolean>] is the type of zipWith’s parameter list, and [number, string, boolean] is type of f’s parameter list. Thus, we can use Zip directly in the type of zipWith:

function zipWith<Arrays extends ReadonlyArray<any>[], R>(f: (...args: Zip<Arrays>) => R, ...arrays: Arrays): ReadonlyArray<R> {
  const len = Math.min(...arrays.map(a => a.length))
  const zipped: R[] = new Array(len)
  for (let i = 0; i < len; i++) {
    // Next line is a type error!
    // TypeScript can’t seem to resolve Zip<Arrays> and f’s parameter list type
    zipped[i] = f(...arrays.map(a => a[i]) as Zip<Arrays>)
  }
  return zipped
}

At first, it may seem surprising that we can reuse Zip. However, it makes perfect sense given that f effectively is receiving the output (albeit incrementally) of the first-order zip, whose return type was derived using Zip.

Unfortunately, TypeScript can’t seem to resolve the Zip<Arrays> type with the type of f’s parameter list in the zipWith implementation. Here’s a playground showing the type error. My intuition is that it has something to do with TypeScript’s handling of parameter variance, but I’ve not investigated it further. If you have any insight, I’d love to hear from you!

Fortunately, there is a solution. Introducing a type parameter for f’s parameter list seems to help TypeScript unify the types, and we can get to a working version:

// Note the additional Args type parameter
function zipWith<Arrays extends ReadonlyArray<any>[], Args extends Zip<Arrays>, R>(f: (...args: Args) => R, ...arrays: Arrays): ReadonlyArray<R> {
  const len = Math.min(...arrays.map(a => a.length))
  const zipped: R[] = new Array(len)
  for (let i = 0; i < len; i++) {
    // Typescript needs the Args hint or it infers any[]
    zipped[i] = f(...arrays.map(a => a[i]) as Args)
  }
  return zipped
}

Here’s a playground of the working version. It’s enlightening to hover over the final zipWith call to see the actual inferred type of the call site. I’ve had issues with TypeScript’s inference in the past, but I have to give kudos in this instance!

Declare f, derive zipWith

Now we know how to make the types work in one “direction”, but we found that we had to help TypeScript with an “extra” type parameter. Let’s try the other direction to see how it compares.

In this case, we’ll declare f’s parameter list type, and derive the type of the tail of zipWith’s parameter list from it. Since we’re working in the opposite direction, we need to invert the direction of the Zip type. Instead of a type-level function that maps [Array<number>, Array<string>] -> [number, string], i.e. from zipWith’s parameter list type to f’s, we need one that performs the reverse mapping, [number, string] -> [Array<number>, Array<string>], i.e. from f’s parameter list type to zipWith’s. Let’s call it UnZip.

// A type-level UnZip.
// For example, it maps types: [number, string] -> [Array<number>, Array<string>].
type UnZip<A extends ReadonlyArray<any>> = {
  [K in keyof A]: ReadonlyArray<A[K]>
}

We can use UnZip to write the type of zipWith:

function zipWith<Args extends any[], R>(f: (...args: Args) => R, ...arrays: UnZip<Args>): ReadonlyArray<R> {
  const len = Math.min(...arrays.map(a => a.length))
  const zipped: R[] = new Array(len)
  for (let i = 0; i < len; i++) {
    // Typescript needs the Args hint or it infers any[]
    zipped[i] = f(...arrays.map(a => a[i]) as Args)
  }
  return zipped
}

Here’s a playground of this version. Once again, TypeScript does quite an amazing job inferring the type of the final call site. It’s a pleasant and unexpected (for me, anyway) bonus that TypeScript infers readonly for each of the arrays.

Which one to use?

Use the one that fits your needs the best.

If the first didn't require the "extra" type parameter, I think I would prefer it from an expressiveness perspective. I feel it expresses the intent more clearly by using the same Zip type to show that f receives zipped tuples. However, the inferred readonly of the second is attractive. Perhaps there is another solution that has both the expressiveness of the first, and the readonly of the second. Until then, pick what's most important to you and go with it.

Generalizing further

Part 1 mentioned how the technique can be generalized to any variadic function whose return type depends on its parameter list. In part 2, we’ve seen that it can also be applied to the relationship between any two variadic parameter lists.

The idea is even more general, and I’ll bet it isn’t new--although I believe its application to variadic functions in TypeScript may be new. It can be applied to express the relationship between any two types, and is a good fit when expressing the types directly doesn’t scale well:

Derive one type from the other using a type-level function.

Take my types, please!

I think this technique has benefits for library maintainers and users, and application developers who write variadic functions. It:

  1. reduces overall code size and error-prone repetition, and
  2. enables use cases and preserves type safety at all arities.

Please consider giving the technique a try! If you do, I’d be interested to hear how it goes, any issues you encounter, and any tweaks or improvements you make.

@Nokel81
Copy link

Nokel81 commented Feb 20, 2021

I am trying to use this but with a slightly different target. I am trying to map the return values of several functions to a union of types.

Namely a version of:

type Predicate<T> = (src: unknown) => src is T;
type UnionPredicate<P extends Predicate<any>[]> = { ... }; // <--- what to put here

function foo<P extends Predicate<any>[]>(...predicates: P): Predicate<UnionPredicate<P>> { ... }

I this possible with the above?

@Nokel81
Copy link

Nokel81 commented Feb 20, 2021

Actually I managed to get it to work:

type Predicate<T> = (arg: unknown) => arg is T;
type Rest<T extends any[]> = T extends [any, ...infer R] ? R : any;
type First<T extends any[]> = T extends [infer R, ...any[]] ? R : any;
type ReturnPredicateType<T extends (src: unknown) => src is any> = T extends (src: unknown) => src is infer R ? R : any;
type OrReturnPredicateType<T extends Predicate<any>[]> = ReturnPredicateType<First<T>> | (T extends [any] ? never : OrReturnPredicateType<Rest<T>>);

export function bindPredicateOr<Predicates extends Predicate<any>[]>(...predicates: Predicates): Predicate<OrReturnPredicateType<Predicates>> {
  return (arg: unknown): arg is OrReturnPredicateType<Predicates> => {
    return predicates.some(predicate => predicate(arg));
  };
}

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