TypeScript Tuple Trickery - Utility Types for Tuples

One of TypeScript’s most powerful features is that you can not only assign certain types to objects, but also derive one type from another by applying a function on the latter. The standard library comes with a lot of built-in utility functions like Partial for making all properties of a type optional, Pick for creating a type with a subset of properties, or Parameters for getting the types of a function’s parameters. However, the library is still lacking utilities to work with tuples - TypeScript’s fixed-size array types.

This blog post introduces some utilities for TypeScript tuples with real-world examples where they have proven handy.

Example 1: Constructing GraphQL Query Result Types

A few weeks ago, we found a bug in our GraphQL query result types. The query looked something like this:

1
2
3
4
5
{
  teaser {
    text
  }
}

with the following TypeScript types

1
2
3
4
5
6
7
interface Video {
  teaser?: {
    text: string;
    ...
  };
  ...
}

For those unfamiliar with GraphQL, this query retrieves the teaser property of a video and picks only the text property from teaser.

We already have well-tested types for our data model. So it’s hardly a stretch to want to use those existing types for our query types. Hence, we use Pick extensively to help us avoid duplicating knowledge about those types.

1
2
3
interface VideoTeaserQuery {
  teaser: Pick<Teaser, 'text'>;
}

Now, VideoTeaserQuery is bound to Teaser but still unconnected to the actual video model. The bug manifested because we forgot that teaser was optional but defined it as required in VideoTeaserQuery. Some client-side code tried to access the teaser without verifying that it is not undefined, which lead to the error. What we need to prevent such bugs is a Pick which goes deeper.

1
2
type VideoTeaserQuery = DeepPick<Video, ['teaser', 'text']>;
// equals { teaser?: { text: string } }

I used a tuple as the second generic type argument to make it possible to go arbitrarily deep. We can recursively apply DeepPick and remove the first element (shift) of the property name tuple with each recursion.

1
2
3
4
5
6
7
type DeepPick<T, K extends any[]> =
    K[0] extends string ?
        {
            [P in keyof Pick<NonNullable<T>, K[0]>]:
                DeepPick<NonNullable<T>[P], ShiftTuple<K>>
        } :
        T;

The only thing that is missing in this declaration is ShiftTuple which should remove the first tuple element and return the rest. Let’s see how we can make this work.

Basics

First, let’s cover some basics on how to derive new types from tuples.

Adding Tuple Elements

Since TypeScript 4.0, you can use variadic tuple types. With this, adding elements to tuple types or even concatenating two tuple types is fairly easy.

1
2
3
4
5
6
7
8
9
type AppendTuple<T extends any[], E> = [...T, E];

type T1 = AppendTuple<[number, string], number>;
// equals [number, string, number]

type PrependTuple<T extends any[], E> = [E, ...T];

type T2 = PrependTuple<[number, string], number>;
// equals [number, number, string]

Concatenating

1
2
3
4
5
type ConcatTuple<T1 extends any[], T2 extends any[]> =
    [...T1, ...T2];

type T3 = ConcatTuple<[number, string], [number, boolean]>;
// equals [number, string, number, boolean]

Removing Tuple Elements

Removing elements is a bit trickier. We need to find out - or more precisely infer - what types the rest of the tuple will have after we removed one element. Luckily, TypeScript’s infer will do exactly that.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type ShiftTuple<T extends any[]> =
  T extends [T[0], ...infer R] ? R : never;

type T4 = ShiftTuple<[number, string, boolean]>;
// equals [string, boolean]

type PopTuple<T extends any[]> =
  T extends [...infer R, infer E] ? R : never;

type T5 = PopTuple<[number, string, boolean]>;
// equals [number, string]

Similarly, you can also retrieve the last element of a tuple.

1
2
3
4
5
type Last<T extends any[]> =
  T extends [...infer R, infer E] ? E: never;

type T6 = Last<[number, string, boolean]>;
// equals boolean

Now that we have ShiftTuple, we have all we need for our DeepPick type from before.

Take a look at the full example and try it out yourself.

Example 2: Ensuring Array Lengths

A few days before we found the GraphQL bug, we stumbled upon a similar issue. We introduced the no-unnecessary-condition eslint rule into our codebase. But we soon realised that it only works properly if TypeScript’s noUncheckedIndexedAccess compiler option is also activated. For code like array[index]?.doSomething(), eslint would state that the ? is unnecessary because TypeScript defines the return value of indexed array accesses as being non-nullable even though the result will be undefined when accessing an invalid index. noUncheckedIndexedAccess fixes that by defining the return type of the indexed access as potentially undefined.

However, this comes with a caveat. You need additional code to check if the index you are trying to access is valid. One of those situations came up the other day when one of the devs in our team tried to access several regex capture groups.

1
2
3
4
5
6
const match = regex.exec(str1);
const data = {
    type: match[2],
    id: match[3],
    locale: match[5]
}

The straight forward solution would be to add a check like if (match[2] && match[3] && match[5]) {…​}. But we asked ourselves, "Can we do better?" What we would need is a type guard which ensures the length of an array.

1
2
3
function hasLength6<T>(array: T[]): array is [T, T, T, T, T, T] {
    return array.length === 6;
}

This will help properly narrowing match[2], match[3] and match[6] to be defined.

1
2
3
4
5
6
7
8
const match = regex.exec(str1);
if (hasLength6(match)) {
    const data = {
        type: match[2],
        id: match[3],
        locale: match[5]
    }
}

However, our function is now tied to one specific length — 6. What we need is a function generic enough to work with any length. We can achieve this by making the length a generic type variable and then return a tuple of the specified length containing only the provided type.

1
2
3
4
function hasLength<T, N extends number>(array: T[], n: N) : arrays is UniformTuple<T, N> {
return array.length === n;
}
// where UniformTuple<string, 3> equals [string, string, string]

But what would the type UniformTuple look like? When my colleague asked me that question, my first reaction was, "That’s not possible with TypeScript". But after giving it a second thought and some research, I found a solution.

1
2
3
4
type UniformTuple<L extends number, T, R extends T[] = []> =
    R extends { length: L } ?
        R :
        UniformTuple<L, T, [...R, T]>;

The trick is to check if a tuple has a certain length using extends { length: L }. The rest is just recursively adding elements to the tuple until it has the required length.

However, there is one problem with this solution. It does not work on unions of numbers like shown in the next example.

1
type StringArrayWithLength2Or4 = UniformTuple<string, 2 | 4>;

The resulting type will be [string, string] instead of [string, string] | [string, string, string, string] because the recursion adding elements to the tuple type stops as soon as one of the numbers from the union types matches its length - which will always be the smallest, e.g. 2 in our example.

If you need the type to support this, you can adjust it to not abort the recursion early but to repeat the process up to a certain max length, e.g. 20 in this example.

1
2
3
4
5
type UniformTuple<T, L extends number, R extends T[] = []> =
    R extends {length: 20} ? never : (
        (R extends { length: L } ? R : never)
            | UniformTuple<T, L, [...R, T]>
    )

With this in our tool belt, activating noUncheckedIndexedAccess becomes much less bothersome.

1
2
3
const inputEls = document.querySelectorAll('input');
assert(hasLength(inputEls, 4));
const value3 = inputEls[3].value;

Check out the full example in the TypeScript playground.

About the author
Philipp Mitterer is a frontend dev with an affinity for user experience, performance and code quality, always seeking new challenges.

read more from Philipp