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
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 number
s:
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.