Arrays & Tuples
After learning about object types in the previous chapter, let's take a look at the second most important data structure of Type-level TypeScript – Tuples.
It might come as a surprise, but Tuples are much more interesting than Arrays at the type level. In fact, they are the real arrays of type-level programs. In this chapter, we are going to learn why they are so useful and how to use all their awesome features. Let's get started!
Tuples
Tuple types define sets of arrays with a fixed length, and each index can contain a value of a different type. For example, the tuple [string, number]
defines the set of arrays containing exactly two values, where the first value is a string
and the second value is a number
.
Tuples are essentially lists of types! They can contain zero, one, or many items, and each one can be a completely different type. Unlike unions, types in a tuple are ordered and can be present more than once. They look just like JavaScript arrays and are their type-level equivalent:
type Empty = [];
type One = [1];
type Two = [1, "2"]; // types can be different!
type Three = [1, "2", 1]; // tuples can contain duplicates
Reading indices of a tuple
Just like with JS arrays, you can access a value inside a tuple by using a numerical index:
type SomeTuple = ["Bob", 28];
type Name = SomeTuple[0]; // "Bob"
type Age = SomeTuple[1]; // 28
The only difference is that tuples are indexed by number literal types and not just numbers.
Reading several indices
We have seen in Objects & Records that it was possible to simultaneously read several keys from an object using a union of string literals:
type User = { name: string; age: number; isAdmin: true };
type NameOrAge = User["name" | "age"]; // => string | number
We can do the same with tuples by using a union of number literal types!
type SomeTuple = ["Bob", 28, true];
type NameOrAge = SomeTuple[0 | 1]; // => "Bob" | 28
We can simultaneously read all indices in a tuple T
with T[number]
:
type SomeTuple = ["Bob", 28, true];
type Values = SomeTuple[number]; // "Bob" | 28 | true
T[number]
is essentially a way of turning a list into a set at the type level.
Remember the trick of using O[keyof O]
to get the union of all values in an object type O
? Well, T[number]
is how you do the same thing with tuples.
But couldn't we use keyof
here as well?
Can we use keyof
?
Technically we can, but the keyof
keyword will not only return all indices but also the names of all methods on the prototype of Array, like map
, filter
, reduce
, etc:
type Keys = keyof ["Bob", 28]; // "0" | "1" | "map" | "filter" | ...
const key: Keys = "map"; // ✅ 😬
keyof
is a bit impractical with tuples, so we rarely use it.
Concatenating tuples
Just like with JS arrays, we can spread the content of a tuple into another one using the ...
rest element syntax:
type Tuple1 = [4, 5];
type Tuple2 = [1, 2, 3, ...Tuple1];
// => [1, 2, 3, 4, 5]
And here is how to merge 2 tuples together:
type Tuple1 = [1, 2, 3];
type Tuple2 = [4, 5];
type Tuple3 = [...Tuple1, ...Tuple2];
// => [1, 2, 3, 4, 5]
Using ...
to merge tuples is very handy! It will become an extremely powerful tool in our toolbox once we start combining it with other features like conditional types.
Named Indices
The Tuple syntax allows giving names to indices. Just like with objects, names precede values, and names and values are separated by a colon :
character:
type User = [firstName: string, lastName: string];
Names help disambiguate the purpose of values of the same type, which makes them pretty useful. They help us understand the kind of data we are dealing with but they don't affect the behavior of the type-checker in any way.
Optional indices
A lesser-known feature of tuples is their ability to have optional indices! To mark an index as optional, you only need to add a question mark ?
after it:
type OptTuple = [string, number?];
// ^ optional index!
const tuple1: OptTuple = ["Bob", 28]; // ✅
const tuple2: OptTuple = ["Bob"]; // ✅
const tuple3: OptTuple = ["Bob", undefined]; // ✅
// ^ we can also explicitly set it to `undefined`
Arrays
In TypeScript, array types are extremely common. They represent sets of arrays with an unknown length. All of their values must share the same type, but since this type can be a union, they can also represent arrays of mixed values:
In TypeScript, Array types can be created in two equivalent ways: either by adding square brackets after a type, like number[]
or by using the more explicit Array<number>
generic:
type Tags = string[];
type Users = Array<User>; // same as `User[]`
type Bits = (0 | 1)[];
Since all values in an Array
have the same type, arrays don't contain a ton of type-level information — they're just wrappers around a single type. In that regard, they are very similar to Records:
// `Arrays` are similar to `Records`:
type BooleanRecord = { [k: string]: boolean };
type BooleanArray = boolean[];
BooleanRecord
and BooleanArray
:
- both have an unknown number of keys or indices
- both contain a single type that is shared between all of their values
In the upcoming chapters of this course, we will mainly focus on object types and tuples. Since they are the type-level equivalents of our good old objects and arrays that we already know so well, we will be able to use them in algorithms we are already familiar with, like recursive loops!
Extracting the type in an Array
Just like with Tuples, we can read the type of values in an array by using the number
type:
type SomeArray = boolean[];
type Content = SomeArray[number]; // boolean
Mixing Arrays and Tuples
Since the introduction of Variadic Tuples, we can use the ...
rest element syntax to mix Arrays and Tuples. This allows us to create types representing arrays with any number of values, but with a few fixed types at specific indices.
// number[] that starts with 0
type PhoneNumber = [0, ...number[]];
// string[] that ends with a `!`
type Exclamation = [...string[], "!"];
// non-empty list of strings
type NonEmpty = [string, ...string[]];
// starts and ends with a zero
type Padded = [0, ...number[], 0];
This is great to capture some of the invariants of our code that we often don't bother typing properly. For example, a French social security number always starts with a 1
or a 2
. We can encode this with this type:
type FrenchSocialSecurityNumber = [1 | 2, ...number[]];
Neat!
Tuples & Function Arguments
Now, if we combine variadic tuples, named indices, and optional indices, here is the kind of tuples we can create:
type UserTuple = [name: string, age?: number, ...addresses: string[]];
Feels familiar, doesn't it? It looks just like functions arguments:
function createUser(name: string, age?: number, ...addresses: string[]) {}
We could also have used our UserTuple
to type the same function:
function createUser(...args: UserTuple) {
const [name, age, ...addresses] = args;
// ~~~~ ~~~ ~~~~~~~~~
// ^ ^ ^
// string number string[]
}
createUser("Gabriel", 29, "28 Central Ave", "7500 Greenback Ln"); // ✅
createUser("Bob"); // ✅ `age` is optional and addresses can be empty.
createUser("Alice", 0, false);
// ~~~~~ ❌ not a `string`!
Using tuples to type function parameters can be convenient if you want to share the types of parameters between several different functions:
function createUser(...args: UserTuple) {}
function updateUser(user: User, ...args: UserTuple) {}
or if your function has several signatures:
type Name =
| [first: string, last: string]
| [first: string, middle: string, last: string];
function createUser(...name: Name) {}
createUser("Gabriel", "Vergnaud"); // ✅
createUser("Gabriel", "Léo", "Vergnaud"); // ✅
createUser("Gabriel"); // ❌
createUser("Oops", "Too", "Many", "Names"); // ❌
Leading Rest Elements
Now that you have seen how similar tuples and function arguments look, you may be thinking that we can always swap one for the other.
This is true in most cases, but here is something you wouldn't be able to do with regular function arguments — Leading Rest Elements. It's the ability to use the ...
syntax before other elements in a tuple type.
As an example, let's type the zipWith function from lodash. zipWith(...arrays, zipper)
takes several arrays, a "zipper" function, and zips them into a single array by calling the zipper function with all values for each index.
Here is a possible way of typing zipWith
:
type ZipWithArgs<I, O> = [
...arrays: I[][], // <- Leading rest element!
zipper: (...values: I[]) => O
];
declare function zipWith<I, O>(...args: ZipWithArgs<I, O>): O[];
// ^ The `declare` keyword lets us define a type
// without specifying an implementation
const res = zipWith(
[0, 1, 2, 3, 4],
[1930, 1987, 1964, 2013, 1993],
[149, 170, 186, 155, 180],
(index, year, height) => {
// index, year, and height are inferred as
// numbers!
return [index, year, height];
}
)
You couldn't type this function with regular parameter types because the JavaScript syntax doesn't support leading rest elements:
declare function zipWith<I, O>(
...arrays: I[][], /* ~~~
^ ❌ A rest parameter must be last in a parameter list */
fn: (...values: I[]) => O
): O[];
The good news is that they are supported at the type level!
🤔 What if my arrays contain values of different types?
You may have noticed that our way of typing zipWith
is a bit... simplistic.
We expect all arrays to contain values of the same type, but our real-world code would surely need to zip arrays of different types too:
/**
* With arrays containing different types, We need to
* tell TypeScript to consider them as arrays of unions
* 👇 **/
const zipped = zipWith<number | string, string>(
[1, 2, 3],
["a", "b", "c"],
(num, char) => {
/** 👆
* both `num` and `char` are inferred as `number | string`.
* Ideally, `num` would be of type `number` and `char`
* of type `string`. **/
return "😭";
}
);
But how can we make this work?
Well, we just need to throw a bit of advanced type-level TypeScript into the mix!
If the next code block is a bit overwhelming don't worry. You will learn how to read and write these kinds of complex types in upcoming chapters. Stay tuned 😊
declare function zipWith<Lists extends [any[], ...any[]], O>(
...args: ZipWithArgs<Lists, O>
): O[];
type ZipWithArgs<Lists extends any[][], O> = [
...arrays: Lists,
zipper: (...values: ComputeValues<Lists>) => O
];
type ComputeValues<Lists> = {
[I in keyof Lists]: Lists[I] extends (infer Value)[]
? Value
: never
}
Let's see if this works 👇
const zipped1 = zipWith(
[1, 2, 3],
["a", "b", "c"],
(num, char) => {
return [num, char];
// ^ ^
// ✅ number string 🎉
}
);
const zipped2 = zipWith(
[6, 7],
['Hello', 'Bonjour'],
[true, false, false],
(num, str, bool) => {
return [num, str, bool];
// ^ ^ ^
// ✅ number string boolean 😍
}
);
💡 Press the "Edit" button at the top-right corner of any code block to see type-checking in action.
Isn't it amazing?
I love that we don't need to write a single type annotation when using zipWith
and still get full type safety because we told TypeScript how to infer types for us!
🤔 What does the implementation of zipWith
look like?
This chapter focuses on types, but here is a possible implementation of the zipWith
function in case you are curious:
function zipWith<I, O>(...args: ZipWithArgs<I, O>): O[] {
// retrieve arrays and zipper arguments
const arrays = args.slice(0, args.length - 1) as I[][];
const zipper = args[args.length - 1] as (...values: I[]) => O;
// get the minimum length in the array of arrays
const minLength = Math.min(...arrays.map((a) => a.length));
// run the zipper function for each index
let output: O[] = Array.from({ length: minLength });
for (let i = 0; i < minLength; i++) {
output[i] = zipper(...arrays.map((a) => a[i]));
}
return output;
}
Summary
In this chapter, we learned about the true arrays of the type level — Tuples. We have seen how to create them, how to read their content, and how to merge them to form bigger tuples!
We have also talked about Array
types, which represent arrays with an unknown number of values all sharing the same type. Arrays and tuples are complementary — we can mix them together in variadic tuples.
Time to practice! 💪
As always, let's finish with a few challenges to put our new skills to work!
💪 Awesome work!
If you enjoyed this free chapter of Type-Level TypeScript, consider supporting it by becoming a member!
Enroll in Type-Level TypeScript!
Get access to all chapters of the Type-Level TypeScript course and join a community of 1600+ students!
You will find everything you need to become a TypeScript Expert — 11 chapters of in-depth, unique content, and more than 70 fun challenges to practice your new skills.
Full access to all 11 chapters
70 type challenges with explanations
Lifetime access to all course materials
Exclusive discord community
Loading...