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:
|
|
with the following TypeScript types
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
Concatenating
|
|
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.
|
|
Similarly, you can also retrieve the last element of a tuple.
|
|
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.
|
|
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.
|
|
This will help properly narrowing match[2]
, match[3]
and match[6]
to be defined.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
With this in our tool belt, activating noUncheckedIndexedAccess
becomes much less bothersome.
|
|
Check out the full example in the TypeScript playground.