Skip to content
Theory · Arrays and tuples · lesson 4 of 7

Tuples

A row of lockers with one sign works when all the items are of one kind. But some data is built differently. Take an ID card: the first field holds a name, the second an age. There are exactly two fields, their order never changes, and each holds its own kind of thing: the name is a string, the age a number. A guard reads the first field as the name and does not check whether a name is there.

In JavaScript such pairs are often kept in an array: ["Аня", 30]. But TypeScript sees an ordinary array in this literal:

TypeScript
const pair = ["Аня", 30];
const first = pair[0];
console.log(first.toUpperCase()); // ✗
TEXT
error TS2339: Property 'toUpperCase' does not exist on type 'string | number'.

You know that the name sits at position zero. TypeScript does not: for the literal ["Аня", 30] it inferred (string | number)[], an array in which any element is a string or a number. So to TypeScript pair[0] is a string | number, and a number has no toUpperCase method.

Why is that? An array may change: an element gets added, the array gets sorted. TypeScript does not guess that you meant a pair, and infers a type that stays true after such changes too.

A type with a slot for each value

A tuple is an array of a fixed length where each position has its own type. A tuple type is written as a list of types in square brackets: [string, number] reads "a string at position 0, a number at position 1".

TypeScript
const user: [string, number] = ["Аня", 30];
console.log(user[0].toUpperCase()); // АНЯ
console.log(user[1].toFixed(1)); // 30.0
console.log(user.length); // 2

Let's go through the example line by line:

  1. The annotation [string, number] says that user is a tuple of two positions.
  2. user[0] is a string, so toUpperCase works and prints АНЯ.
  3. user[1] is a number, and toFixed(1) prints 30.0.
  4. The tuple's length is known in advance: user.length has the type 2, and the program prints 2.

The literal itself did not change; it is the same ["Аня", 30]. The annotation changed: you get a tuple only when you tell TypeScript about it yourself.

Past the end of a tuple

An ID card has no third field, and the tuple [string, number] has no position 2. tsc does not let the read user[2] through:

TEXT
error TS2493: Tuple type '[string, number]' of length '2' has no element at index '2'.

The message means "the tuple [string, number] of length 2 has no element at index 2". In JavaScript such a read would silently return undefined.

The main thing in this lesson is where a tuple comes from. On its own, TypeScript infers a literal as an array, and a value becomes a tuple through an annotation. Step through the program and compare the type of pair with the type of user, then the types of their elements:

LiveArray or tuplestep 1 of 6

From the literal, TypeScript inferred an array in which any element is a string or a number.

Press "Next step": the hover shows a type or an error on the highlighted line

The value in pair and in user is the same, yet the types differ: without an annotation it is an array, with one it is a tuple.

Destructuring a tuple

Destructuring is a handy way to take a tuple apart: each variable gets the type of its position. This way a function can return two values at once:

TypeScript
function minMax(nums: number[]): [number, number] {
    return [Math.min(...nums), Math.max(...nums)];
}

const [low, high] = minMax([3, 9, 1]);
console.log(low, high); // 1 9

Let's see what happens here:

  1. The result type [number, number] promises a pair: the minimum and the maximum.
  2. const [low, high] spreads the pair over two variables, and both have the type number.
  3. The program prints 1 9.

The result annotation is needed here. Without it, TypeScript would infer the array number[] from the return, and the calling code would not know there are exactly two elements.

Interviewers like this question: how does a tuple differ from an array? An array has any length and one element type for all positions. A tuple fixes both the length and the type of each position.

An optional position

The last positions of a tuple can be made optional with ?: in [string, number?] the age may be left out. After const guest: [string, number?] = ["Борис"];, guest.length has the type 1 | 2, and guest[1] has the type number | undefined, so it must be checked before use. Optional positions come only after the required ones.

A tuple is still an array

Here the ID-card analogy ends. You cannot add a field to a printed card, but a tuple is still a JavaScript array. The call user.push(5) compiles, and the length becomes 3, although the type still speaks of two positions. You can forbid this the same way as for an array: readonly [string, number] is a read-only tuple, and it has no push.

Common mistake

A pair was put into a variable without an annotation and then passed where a tuple is expected:

TypeScript
function greet(user: [string, number]) {
    console.log(`${user[0]}, ${user[1]}`);
}

const ann = ["Аня", 30];
greet(ann); // ✗
TEXT
error TS2345: Argument of type '(string | number)[]' is not assignable to parameter of type '[string, number]'.

ann has the type (string | number)[]: such an array may hold any number of elements in any order, while greet expects exactly a name and an age. There are two ways to fix it:

  • write an annotation on the variable: const ann: [string, number] = ["Аня", 30];;
  • pass the literal straight into the call: in greet(["Борис", 25]) TypeScript reads the literal by the parameter's type and sees a tuple at once.

Key points

  • A tuple is an array of a fixed length with its own type at each position: [string, number].
  • Without an annotation, TypeScript infers the literal ["Аня", 30] as the array (string | number)[].
  • A read past the end of a tuple is error TS2493, while push into a tuple compiles unless it is readonly.
  • The array (string | number)[] does not fit where [string, number] is expected: it needs an annotation.