Tricks with Variadic Tuple Types with TypeScript 4

Tricks with Variadic Tuple Types with TypeScript 4

TypeScript 4 released recently and the most interesting new feature is variadic tuple types. They have a lot of promise for functional programming in TypeScript, as they enable really sophisticated typing of the arguments of higher-order functions. But they also have some utility for more conventional TypeScript users working with classes and methods. Here's two tricks I've found so far.

Partial Function Application

In general, functional programmers really like partially applying functions. The pattern has a lot of utility in many different areas, but it can often be thought of simply as "binding" or "fixing" one or more of the arguments of a function, so that you're left with a new function that needs the other remaining "unbound" arguments. TypeScript users of all kinds do this all the time with the following pattern:

function baseFunction(a: string, b: number, c: Record<string, number>, d: number[]) {
    // etc.
}

function partiallyApplyBaseFunction(a: string, b: number) {
    return (c: Record<string, number>, d: number[]) => baseFunction(a, b, c, d);
}

const partiallyAppliedBaseFunction = partiallyApplyBaseFunction('foo', 2);
// typeof partiallyAppliedBaseFunction is (c: Record<string, number>, d: number[]) => void

This works perfectly well, but it can get messy if baseFunction() ends up having a really complicated signature. With variadic tuple types, this whole pattern can be generalized:

function partiallyApply<
    Result,
    FirstArgs extends unknown[],
    RestArgs extends unknown[]
>(
    fn: (...allArgs: [...FirstArgs, ...RestArgs]) => Result,
    ...firstArgs: FirstArgs
) {
    return (...restArgs: RestArgs) => fn(...firstArgs, ...restArgs);
}


function baseFunction(a: string, b: number, c: Record<string, number>, d: number[]) {
    // etc.
}

const partiallyAppliedBaseFunction = partiallyApply(baseFunction, 'foo', 10);
// typeof partiallyAppliedBaseFunction is still (c: Record<string, number>, d: number[]) => void

partiallyApply() itself has a decently complex signature, but the result is that it accommodates any function, no matter no complex its signature, and we avoid having to separately write and type single-purpose partial application functions like partiallyApplyBaseFunction() in the earlier example.

We can also easily handle applying arguments from the end of the function signature largely by just switching the ordering of [...FirstArgs, ...RestArgs], but applying just specific arguments in the middle of the signature is not so easy to generalize like this.

Alternative Style of Function Overloading

You've been able to overload functions and methods in TypeScript for a while. The language does not support overloading in the vein of Java or C# where

  • you have multiple implementations of a function, and
  • the types of the arguments you provide when calling this function determine which implementation is used.

Instead, TypeScript allows you to correctly type a function that is "overloaded" in the classic JavaScript manner, which is where

  • you have one function and implementation that
  • handles a variety of argument signatures of different length.

Crucially, this means that an overloaded function's different signatures are chiefly differentiated by their length. A classic example of this is the lodash range() function, which handles anywhere from one to three arguments. Here's a naïve implementation of that function in TypeScript:

function range(end: number): number[];
function range(start: number, end: number): number[];
function range(start: number, end?: number, step?: number): number[] {
  if (end === undefined) {
    [start, end] = [0, start];
  }

  if (step === undefined) {
    step = end >= start ? 1 : -1;
  }

  const result = [];
  for (let i = start; i < end; i = i + step) {
    result.push(i);
  }

  return result;
}

This is straightforward enough but I can often catch myself making mistakes with this classic form of overloading in TypeScript because the language forces you to ensure the final "implementation" form of the signature is compatible with the others. This doesn't cause many problems for range() but it can manifest in quirky ways elsewhere.

As an example, imagine we have a class that's intended to represent a number but the number actually "lives" at some remote resource which requires asynchronous calls to check and update. So the class instead implements some methods to interface with the number.

abstract class IndirectNumber {
  protected abstract async getNumber(): Promise<number>;
  protected abstract async setNumber(newNumber: number): Promise<void>;

  public async increment(incrementValue: number): Promise<void> {
    const prevValue = await this.getNumber();
    await this.setNumber(prevValue + incrementValue);
  }

  public async is(predicate: (num: number) => boolean): Promise<boolean> {
    const actualValue = await this.getNumber();
    return predicate(actualValue);
  }
}

This is OK, but things get more interesting if you want to instead represent one or more numbers. Assume that each of these numbers we're representing requires its own async call, so we want to avoid fetching them all at once unless we really need to. Your first implementation might look like this:

abstract class IndirectNumbers {
  protected abstract getNumberIds(): string[];
  protected abstract async getNumber(id: string): Promise<number>;
  protected abstract async setNumber(id: string, newNumber: number): Promise<void>;

  public async increment(incrementValue: number): Promise<void> {
    const idsToIncrement = this.getNumberIds();
    await Promise.all(idsToIncrement.map(async (id) => {
      const prevValue = await this.getNumber(id);
      await this.setNumber(id, prevValue + incrementValue);
    }));
  }
}

This intersects with overloading again once we observe that these methods are not quantified over the collection of numbers. Basically, does increment() increment all the numbers, or just a particular number?

Here is how you would traditionally overload that function so that you can call either indirectNum.increment(1) or indirectNum.increment('id', 1):

abstract class IndirectNumbers {
  protected abstract getNumberIds(): string[];
  protected abstract async getNumber(id: string): Promise<number>;
  protected abstract async setNumber(id: string, newNumber: number): Promise<void>;
  
  public async increment(incrementValue: number): Promise<void>;
  public async increment(id: string | number, incrementValue?: number): Promise<void> {
    // etc.
  }
}

I think this signature ends up being really ugly. In particular, the type system allows you to pass in a bare id, such as increment('id'), which is contrary to the design of the function. It may be that it's possible enforce this constraint with the type system, but either way the language is not naturally leading you to that correct pattern.

In contrast, variadic tuple types allow us to instead type the arguments of this function as a union of tuples:

abstract class IndirectNumbers {
  protected abstract getNumberIds(): string[];
  protected abstract async getNumber(id: string): Promise<number>;
  protected abstract async setNumber(id: string, newNumber: number): Promise<void>;
  
  
  public async increment(...args:
    | [incrementValue: number]
    | [id: string, incrementValue: number]
  ): Promise<void> {
    let idsToIncrement;
    let valueToIncrement;
    if (args.length === 1) {
      const [incrementValue] = args;
      idsToIncrement = this.getNumberIds();
      valueToIncrement = incrementValue;
    } else {
      const [id, incrementValue] = args;
      idsToIncrement = [id];
      valueToIncrement = incrementValue;
    }
    // etc.
  }
}

In this case we are saying that the increment() function takes a variable number of arguments (...args) and that the type of this array of arguments is either [number] or [string, number].  These tuples additionally have labels (incrementValue:, id:) but that's just for prettier auto-completion elsewhere in the codebase.

The upshot of this is that we have explicitly prohibited a single string argument such as increment('anId'), which was 'valid' in the prior example. I also appreciate the simplicity of discriminating between the possible cases by just checking the length of the args array.

Show Comments