Command Palette

Search for a command to run...

Hectal
PHASE 5Beginner ~27 min· topic 2 of 6

Topic 5.2

Tuples

In one line

A tuple is an array whose length and the type at each position are fixed, like [string, number] for a name and an age. Functions use tuples to return several values at once, the way useState does.

Think of it like this

A train ticket has exactly three boxes in a fixed order: the from station, the to station, the seat number. A shopping list, by contrast, can be any length and every item is the same kind of thing. A normal array is the shopping list. A tuple is the ticket: how many boxes there are, and what goes in each one, is part of the deal.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Tuple
An array with a fixed number of items where each position has its own type.
Element
One item in a tuple or array. Also called an entry.
Optional element
A tuple slot marked ? that may be left out.
Rest element
A ... slot that stands for any number of extra items of one type.
Label
A name attached to a tuple position, like start: number. For readers only.
Variadic tuple
A tuple type built by spreading other tuple types into it with ....
Destructuring
Pulling items out of an array or object into named variables, like const [a, b] = pair.
Return type annotation
The type written after a function's parameters, ): [string, number].

Step by step

01A pair with a fixed shape

To say "exactly a number and then a number", write the types in square brackets: [number, number]. Reading point[0] gives number. Reading point[2] is an error, because there is no third slot.

point.tswhole filets
const point: [number, number] = [3, 4];
const [x, y] = point; // x: number, y: number
point[2]; // error TS2493

02Optional and rest elements

Add ? to make a slot optional, and ...name: type[] to accept any number of extra items. Optional slots come after required ones. A rest slot comes last (or just before fixed trailing items).

Labels such as start and tags are free documentation: hover text and autocomplete show them.

shapes.tswhole filets
type Range = [start: number, end?: number];
type Row = [id: number, ...tags: string[]];

03Return several values

A function can only return one value, but that one value can be a tuple. The caller destructures it and names the pieces. Write the tuple in the return type so TypeScript doesn't loosen it to an array of unions.

counter.tswhole filets
function useCounter(start: number): [count: number, increment: () => void] {
  let count = start;
  return [count, () => { count += 1; }];
}
const [count, increment] = useCounter(10);

04Spread tuples into longer tuples

Variadic tuples build a new tuple type from pieces. [...A, ...B] means "all of A, then all of B". The checker computes the exact result for the types you give.

join.tswhole filets
type Join<A extends unknown[], B extends unknown[]> = [...A, ...B];
type Both = Join<[string, number], [boolean]>; // [string, number, boolean]

05Array or tuple?

The question to ask is: "Does the position carry meaning?" If yes (from/to, key/value, value/setter), use a tuple. If every item is the same kind and the count varies, use an array.

Array or tuple?diagram
Rendering diagram…

Try it yourself

  1. 1

    Close the push hole

    In the first example, change the annotation of point to readonly [number, number]. Predict which lines change: point.push(9) becomes an error (Property 'push' does not exist on type 'readonly [number, number]').

  2. 2

    Add a slot

    In the third example, change the return type of useCounter to [count: number, increment: () => void, reset: () => void]. Predict the error on the return line before you read it: the returned array has two items and the type needs three.

  3. 3

    Read the label

    Hover (or use // ^? on) useCounter in the third example. The labels count and increment appear in the signature. Remove the labels and see what the hover shows instead.

Code & diagrams

A tuple enforces length and position types

Four errors, four different mistakes. The last line shows the hole: push is not an error, so the pair now has three items at runtime. Change the annotation to readonly [number, number] and push becomes an error.

Console

(nothing was printed)

Expected console output

3 3 4
Optional, rest and labelled elements TypeScript 4.0+

end is number | undefined because the slot is optional, so the function must handle the missing case. Compare n0 and p0: an index into an array might be missing (string | undefined), an index into a tuple slot is known to exist (string).

Console

(nothing was printed)

Expected console output

from 1 1 to 5 [ 7, 'a', 'b' ]
Returning several values, and variadic tuples TypeScript 4.0+

The annotated function gives count a clean number. The un-annotated loose() is inferred as an array of a union, so n becomes number | (() => void) | undefined: awkward to use. as const gives a readonly tuple with exact values. Join builds a three-item tuple type; a two-item array no longer fits it. (count is still 10 after increment() because the tuple holds a copy of the number: only the function sees the changed variable.)

Console

(nothing was printed)

Expected console output

10
[ 'a', 1, true ]

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget the tuple annotation

In the first example, delete the annotation on line 1 (const point = [3, 4];) and add const pair: [number, number] = point; as the last line.

terminal
$ Types panel
── what you'll see ──
index.ts(9,7): error TS2322: Type 'number[]' is not assignable to type '[number, number]'.
Target requires 2 element(s) but source may have fewer.

Break #2

Let the return type be inferred

Write a function with no return annotation that returns [1, () => {}], destructure it with const [n] = loose(); and hover n with // ^? (this is loose() in the last live example).

terminal
$ Types panel (the // ^? query)
── what you'll see ──
index.ts(13,8): const n: number | (() => void) | undefined

Myth vs fact

Myth

A tuple is a different kind of object from an array at runtime.

Fact

It is a plain JavaScript array. The fixed length and position types exist only in the type system and are erased.

Myth

TypeScript stops you from changing a tuple's length.

Fact

Assignment is checked, but methods like push and pop still compile on a mutable tuple. Use readonly tuples to close that hole.

Myth

If I write [1, 2] TypeScript infers a tuple.

Fact

It infers number[]. You get a tuple only from a tuple annotation, as const, or a context that expects one (such as a contextually typed return).

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Tuples are the foundation of function parameter lists in the type system: Parameters<F> returns a tuple, rest parameters (...args: [string, number]) are tuples, and Function.prototype.bind/call/apply are typed with them when strictBindCallApply is on (part of strict). Variadic tuples (4.0) are what make these typeable for functions of any arity.

  • ▸

    Optional tuple elements have the type T | undefined on read. With exactOptionalPropertyTypes on, you cannot write [1, undefined] into [number, number?] (TS2322, "Type 'undefined' is not assignable to type 'number'"); the optional slot means "may be absent", not "may hold undefined". This is the same rule as optional properties on objects. Without the flag, the write is allowed.

  • ▸

    A tuple with a rest element, such as [number, ...string[]], has an unbounded length, so length is number, while a fixed tuple's length is a literal type (2). That literal is what lets conditional types and switch (t.length) narrow tuples (Phase 9).

  • ▸

    Tuple types in return positions mean allocating an array on every call. In hot code paths, or when more than three values are returned, an object ({ value, setValue }) is often easier to read and to extend without breaking callers, because you can add a field without reordering anything.

Remember this

  1. 1

    The problem this solves: A list of mixed values, like a name and an age, can't be described as string[] or number[], and the position of each value matters. An array type like string[] says "any number of strings". A tuple type like [string, number] says "exactly two items: first a string, then a number". At runtime a tuple is still an ordinary JavaScript array; the fixed shape exists only in the type system. TypeScript uses it when you give the type, or when you write as const (Topic 5.1), and in places such as Object.entries, which returns [string, T][].

  2. 2

    The checker enforces three things. Length: [1, 2, 3] doesn't fit [number, number] (Source has 3 element(s) but target allows only 2). Position types: [5, "a"] doesn't fit [string, number], and each wrong item gets its own error. Reading: point[2] on a two-item tuple is an error (TS2493), while on an ordinary array an out-of-range read compiles and is undefined at runtime (with noUncheckedIndexedAccess, from tsc --init, the type honestly says string | undefined).

  3. 3

    Tuples can have optional elements with ? ([start: number, end?: number]), which must come after the required ones, and a rest element with ... ([id: number, ...tags: string[]]: one number, then any number of strings). Reading an optional element gives number | undefined, so you handle the missing case. Under exactOptionalPropertyTypes (also from tsc --init) you also can't write an explicit undefined into the slot: [1, undefined] is rejected for [number, number?].

  4. 4

    Labelled elements (TypeScript 4.0) give each position a name: [count: number, increment: () => void]. Labels change nothing about what is allowed; they show up in hover text and in autocomplete and tell the reader what each slot means. Before TypeScript 5.2 you had to label all the elements or none; since 5.2 you may label some and not others.

  5. 5

    Variadic tuples (TypeScript 4.0) let you spread one tuple type into another: [...A, ...B] is A's items followed by B's items. In the live example below, the blank types A and B are generic parameters (Phase 7 explains them; for now read Join<A, B> as "glue two tuple types together"). This is how libraries type functions that take or return lists of different lengths, for example Promise.all and event handlers.

  6. 6

    The most useful everyday use is a function that returns several values. React's useState returns [value, setValue] as a tuple so that you can pick your own names when you destructure: const [count, setCount] = useState(0). If you write such a function yourself, annotate the return type (or end the returned array with as const). Without that, TypeScript infers the loose array type (number | (() => void))[], and every element becomes a union that you must narrow (check it with typeof or if until TypeScript knows which member it is, Topic 4.3).

  7. 7

    A known soundness hole: tuples are arrays at runtime, so point.push(9) compiles even on [number, number] and silently makes a three-item "pair". readonly [number, number] closes the hole. For small fixed groups of unrelated things prefer a tuple; as soon as the slots need names that matter to readers, an object like { x: number; y: number } is usually clearer.

Explain it without notes

01

What is a tuple type, and how is it different from an array type?

02

Why is const pair = ["a", 1] not a tuple, and how do you make it one?

03

Name two ways to write a function that returns several values, and say when you would use each.

04

What are optional, rest and labelled tuple elements?

Practice

01

Write a function minMax(nums: number[]) that returns the smallest and largest number as a tuple with the labels min and max. Destructure the result and show that reading a third element is an error.

02

Define type Entry = [key: string, value: number] and write const entries: Entry[] with three entries. What does Object.fromEntries(entries) return, and what is its type?

03

Take const pt = [10, 20];. Pass it to function draw(p: readonly [number, number]). It fails. Give two fixes.

Trade-offs

  • ↔

    Tuples are compact and let callers choose their own variable names, but they are positional: swapping two slots of the same type ([number, number]) is a bug no checker can see. Use an object when slots share a type and the order is easy to confuse.

  • ↔

    Variadic tuple types give strong typing for functions of varying arity, at the price of more advanced type-level code and longer error messages. Use them in library-style helpers, not in everyday application code.

Done when you can

  • I can write a tuple type and explain the three errors the checker gives for a wrong length, a wrong position type and an out-of-range read.

  • I can write optional, rest and labelled tuple elements.

  • I can explain why [1, 2] is number[] unless I annotate it or use as const.

  • I can write a function that returns a tuple and annotate its return type.

  • I can explain that push still works on a mutable tuple and how readonly fixes it.