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.
const point: [number, number] = [3, 4];
const [x, y] = point; // x: number, y: number
point[2]; // error TS249302Optional 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.
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.
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.
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.
Try it yourself
- 1
Close the push hole
In the first example, change the annotation of
pointtoreadonly [number, number]. Predict which lines change:point.push(9)becomes an error (Property 'push' does not exist on type 'readonly [number, number]'). - 2
Add a slot
In the third example, change the return type of
useCounterto[count: number, increment: () => void, reset: () => void]. Predict the error on thereturnline before you read it: the returned array has two items and the type needs three. - 3
Read the label
Hover (or use
// ^?on)useCounterin the third example. The labelscountandincrementappear in the signature. Remove the labels and see what the hover shows instead.
Code & diagrams
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 4end 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' ]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.
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).
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, andFunction.prototype.bind/call/applyare typed with them whenstrictBindCallApplyis on (part ofstrict). Variadic tuples (4.0) are what make these typeable for functions of any arity. - ▸
Optional tuple elements have the type
T | undefinedon read. WithexactOptionalPropertyTypeson, 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, solengthisnumber, while a fixed tuple'slengthis a literal type (2). That literal is what lets conditional types andswitch (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
The problem this solves: A list of mixed values, like a name and an age, can't be described as
string[]ornumber[], and the position of each value matters. An array type likestring[]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 writeas const(Topic 5.1), and in places such asObject.entries, which returns[string, T][]. - 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 isundefinedat runtime (withnoUncheckedIndexedAccess, fromtsc --init, the type honestly saysstring | undefined). - 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 givesnumber | undefined, so you handle the missing case. UnderexactOptionalPropertyTypes(also fromtsc --init) you also can't write an explicitundefinedinto the slot:[1, undefined]is rejected for[number, number?]. - 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
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 typesAandBare generic parameters (Phase 7 explains them; for now readJoin<A, B>as "glue two tuple types together"). This is how libraries type functions that take or return lists of different lengths, for examplePromise.alland event handlers. - 6
The most useful everyday use is a function that returns several values. React's
useStatereturns[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 withas const). Without that, TypeScript infers the loose array type(number | (() => void))[], and every element becomes a union that you must narrow (check it withtypeoforifuntil TypeScript knows which member it is, Topic 4.3). - 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
What is a tuple type, and how is it different from an array type?
Why is const pair = ["a", 1] not a tuple, and how do you make it one?
Name two ways to write a function that returns several values, and say when you would use each.
What are optional, rest and labelled tuple elements?
Practice
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.
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?
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]isnumber[]unless I annotate it or useas const.I can write a function that returns a tuple and annotate its return type.
I can explain that
pushstill works on a mutable tuple and howreadonlyfixes it.