Crazy, Powerful TypeScript Tuple Types

23 March 2021

In celebration of TypeScript 4.2 and the language's continued evolution, let's take a look at tuple types and some advanced type manipulations we can do with them.

Fundamentals

A Tuple (rhymes with 'couple', not 'pupil') is a simple container of data. A tuple object has a fixed size, and the type of each element is known but the types don't need to be the same.

A basic tuple definition is:

const movie: [string, number, Date] = ["Seven", 8.6, new Date(1996, 0, 5)];

This movie tuple has exactly 3 elements and they must be string, number and Date in that order. These structures can be easily destructed into their component parts:

const [title, rating, releaseDate] = movie;

The great thing here is that the types of title, rating and releaseDate are correct (string, number and Date). Be careful how you define it though - we can't make use of type inference as the syntax for a tuple literal is the same as an array literal.

const movie = ["Seven", 8.6, new Date(1996, 0, 5)];
const [title, rating, releaseDate] = movie;

Above, the type of movie is (string | number | Date)[] so the type of each destructured variable is the union string | number | Date. Also, realise that at runtime, after the TypeScript code has been converted to JavaScript, tuples (and typed arrays) are implemented as a normal JavaScript arrays. Like all things related to TypeScript, the safety and constraints for tuples only exist at compile time.

Finally, we can also use rest elements within tuples. These allow us to create variadic tuples. For example:

type AtLeastTwoNumbers = [number, number, ...number[]];

const values1: AtLeastTwoNumbers = [1, 2];
const values2: AtLeastTwoNumbers = [1, 2, 3, 4, 5, 6];
const values3: AtLeastTwoNumbers = [1]; // Error

Note that the type of the rest element must itself be a tuple or array type - in this case number[].

Benefits

We could of course have represented a movie as a class or interface, and we will always have this option. However, tuples are especially useful in these scenarios:

  • Returning more than one value from a function
  • Destructuring into variables with arbitrary names
  • Representing parameters of a function (more on this later)

A good example of the first two points is in React's useState function. When you call this function, it returns the current value of a piece of state and a function to update that state.

const [counter, setCounter] = useState(0);
const [error, setError]     = useState('');
const [active, setActive]   = useState(true);

Returning both together in a tuple is useful as you can immediately destructure and use arbitrary names. To fully see the advantage, imagine if the same data was returned within a generic State object. You would need to use more verbose destructuring aliases or use object references.

interface State<T> {
    value: T;
    setter: (_: T) => void;
}

function useState<T>(input: T): State<T> {}

// ...

// More verbose destructuring for unique names
const {value: counter, setter: setCounter} = useState(0);
const {value: error, setter: setError} = useState('');
const activeState = useState(true);

// More verbose object reference if not destructuring
activeState.setter(false);

Function Parameters as Tuples

You've probably seen rest parameters, applying '...' to the last parameter of a function to create variadic functions.

function sum(...numbers: number[]): number {
    return numbers.reduce((total, value) => total + value);
}

sum(1, 2);
sum(1, 2, 3);
sum(1, 2, 3, 5, 8, 13, 21);

At the call site we can use a variable number of arguments. These are gathered into a single array within the sum function.

In TypeScript we can represent the aggregated arguments as an array or a tuple. This allows use to define a function signature in a couple of ways. Consider a normal function taking 3 parameters:

function printEmployee(name: string, age: number, startDate: Date) {}

printEmployee('Eamonn', 21, new Date(2012, 9, 1));

We could rewrite using a rest parameter typed as a tuple:

function printEmployee(...args: [string, number, Date]) {}

printEmployee('Eamonn', 21, new Date(2012, 9, 1));

Or with parameter destructuring:

function printEmployee(...[name, age, startDate]: [string, number, Date]) {}

printEmployee('Eamonn', 21, new Date(2012, 9, 1));

This is interesting, but is it useful? It's useful because the tuple is a single entity representing multiple parameters. This is especially useful when combined with generics where we don't specify what the type is until the call site. Let's look at a practical example.

Examining Promise.all

The Promise type wraps up asynchronous operations and has a few useful static methods for combining multiple promises.

The Promise.all method takes an array of promises and returns a new promise that resolves with all results when all input promises resolve. We can subsequently use the then method or async await to consume the results. For example:

const allResults = await Promise.all([
    Promise.resolve('Eamonn'),
    Promise.resolve(21),
    Promise.resolve(new Date(2012, 9, 1)),
]);

Or with destructuring:

const [name, age, startDate] = await Promise.all([
    Promise.resolve('Eamonn'),
    Promise.resolve(21),
    Promise.resolve(new Date(2012, 9, 1)),
]);

What's interesting here is that name, age and startDate have the expected types. The input parameter types, Promise<string>, Promise<number> and Promise<Date>, are correlated back to the type of the result, a tuple of type [string, number, Date]. Let's look at how this is achieved (I've simplified the definition):

interface PromiseConstructor {
    all<T>(values: [Promise<T>]): Promise<T[]>;
    all<T1, T2>(values: [Promise<T1>, Promise<T2>]): Promise<[T1, T2]>;
    all<T1, T2, T3>(values: [Promise<T1>, Promise<T2>, Promise<T3>]): Promise<[T1, T2, T3]>;
    // ...
    all<T1, T2, T3, T4, T5, T6, T7, T8, T9, T10>(values: [Promise<T1>, /* .. */): Promise<[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10]>;
    // ...
}

As you can see, the definition uses a series of overloads with an increasing number of generic parameters up to a maximum of 10. These generic parameters correlate the input tuple of promises to the output promise of tuple.

I said the code was simplified as Promise.all actually supports taking in promise objects and normal objects. Also, the first overload actually supports any number of inputs of the same type. So the actual definition is:

interface PromiseConstructor {
    all<T>(values: readonly (T | PromiseLike<T>)[]): Promise<T[]>;
    all<T1, T2>(values: readonly [T1 | PromiseLike<T1>, T2 | PromiseLike<T2>]): Promise<[T1, T2]>;
    all<T1, T2, T3>(values: readonly [T1 | PromiseLike<T1>, T2 | PromiseLike<T2>, T3 | PromiseLike<T3>]): Promise<[T1, T2, T3]>;
    // ...
}

The downside here is that it requires multiple overload definitions (redundancy) and it tops out at 10 parameters. If I add an 11th argument, I get a compiler error. Let's see if we can improve it.

Improving Promise.all

Back to the Promise

We can create a function that will capture the tuple type as a single generic parameter.

function promiseAll<T extends [...any[]]>(promises: [...T]): RemapPromises<T> {
    return Promise.all(promises) as RemapPromises<T>;
}

We will look at RemapPromises later but for now realise that it will convert a tuple of promises type to a promise of tuple type.

In this function the generic parameter T is constrained to a tuple of any elements. The type is inferred from usage - for example:

const [name, age, startDate] = await promiseAll([
    Promise.resolve('Eamonn'),
    Promise.resolve(21),
    Promise.resolve(new Date(2012, 9, 1)),
]); // [string, number, Date]

We could also define our function using a rest parameter to make it variadic and dropping the '[]' from the call site.

function promiseAll<T extends [...any[]]>(...promises: [...T]): RemapPromises<T> {
    return Promise.all(promises) as RemapPromises<T>;
}

const [name, age, startDate] = await promiseAll(
    Promise.resolve('Eamonn'),
    Promise.resolve(21),
    Promise.resolve(new Date(2012, 9, 1)),
);

The big win here is that we have a single definition, and we can have more than 10 arguments.

await promiseAll(
    Promise.resolve(1), Promise.resolve('2'), Promise.resolve(3), Promise.resolve(4),
    Promise.resolve('5'), Promise.resolve('6'), Promise.resolve(7), Promise.resolve(8),
    Promise.resolve(9), Promise.resolve('10'), Promise.resolve('11'), Promise.resolve('12'),
); // [number, string, number, number, string, string, number, number, number, string, string, string]

The Remapping Type

One of the complicated bits is the RemapPromises type. Luckily, above, we had it tucked away in an alias so that we didn't have to think about it until now. The solution for this uses some concepts discussed in one of my earlier posts, Crazy, Powerful TypeScript 4.1 Features.

First, we define a type to take a Promise<T> | T and return T.

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

This uses a Conditional Type along with type inference to figure out the inner type of an input promise type. If the input type, T, is not a promise type then it is used directly.

Next, we can use this with a Recursive Conditional Type that does this for all elements within a tuple, converting [Promise<T1>, Promise<T2>, ..., Promise<TN>] into [T1, T2, ..., TN].

type UnwrapPromises<T extends [...any[]]> =
    T extends [infer Head, ...infer Tail]
        ? [UnwrapPromise<Head>, ...UnwrapPromises<Tail>]
        : [];

Here we are using the conditional type and inference again to extract out the Head and Tail of the input tuple. We can then use the previously defined UnwrapPromise to unwrap the Head and recursively call UnwrapPromises (note the 's') to unwrap the tail. These are combined into a resultant tuple.

type TestA = UnwrapPromises<[]>; // []
type TestB = UnwrapPromises<[Promise<number>]>; // [number]
type TestC = UnwrapPromises<[Promise<number>, Promise<string>, Date, boolean]>; // [number, string, Date, boolean]

Finally, we can use this tuple of 'unwrapped' promises to create our final promise of tuple.

type RemapPromises<T extends [...any[]]> = Promise<UnwrapPromises<T>>;

Why is it not implemented like this?

The standard Promise.all definition may eventually change to something like this but there are probably issues which I'm not considering. I am only demonstrating this as a practical example to illustrate what you can do with tuple types, recursive conditional types etc. You can go off and consider other use cases in your own code.

When making changes to a language's standard library, definitions that need to support ALL TypeScript code that exists out there, you need to apply a bit more rigour than I'm doing here. Consideration of backward compatibility, corner cases, compiler performance and more would impact any final definitions. I'd love to hear your thoughts in the comments about any issues with this as a general solution.

What does TypeScript 4.2 Add?

TypeScript 4.2 brings one feature to tuple types, and that's the ability to spread on leading or middle elements. For example, here we have a tuple where the middle elements are a variable length series of numbers:

type Student = [string, ...number[], boolean];

const bob: Student   = ['Bob', true];
const sally: Student = ['Sally', 100, true];
const jane: Student  = ['Jane', 80, 90, 100, true];

This could be useful when combined with rest parameters to create variadic function on leading or middle parameters. Consider an assertion function to test if all values are equal and throw an error with a specific message if not.

function assertAreEqual(...args: [number, number, ...number[], string]): void {
    const [firstValue, ...remaining] = args.slice(0, args.length - 1) as number[];
    const message = args[args.length - 1] as string;

    for (const value of remaining) {
        if (firstValue !== value) throw new Error(message);
    }
}

assertAreEqual(1, 1, 1, 1, "They're not the same");

This function must be called with two or more numbers followed by a string which we can't do with normal rest parameters.

I'm not sure you would create a function of this shape very often. There is no way to easily destructure the tuple as the rest destructuring must always be the last element. So here we use slicing and indexing along with explicit type assertions. I think having multiple functions, overloads or trailing rest parameters will more often be the right answer.

This may prove useful for safely typing JavaScript functions that use this shape though.

Conclusions

Small, basic tuples are very useful in their own right but when you delve a bit deeper and combine with generics, recursive conditional types and rest elements you can achieve some interesting results.

Be sure to check out my TypeScript 4.1 post where I discuss recursive conditionals. Also, to see examples of tuples used with useState, have a look at my React Tutorial.

I hope you found this interesting. Check out our TypeScript course. We're also happy to deliver React training using TypeScript. We deliver virtually to companies all over the world and are happy to customise our courses to tailor to your team's level and specific needs. Come and check us out to see if we can help you and your team.

Article By
blog author

Eamonn Boyle

Instructor, developer