Tuples — positions with labels
Learn TypeScript tuples, fixed-length, position-typed arrays, and the decision rule for when a tuple beats a named-field object.
You’ve been consuming a tuple every day, probably without naming the shape. Open any React component you’ve seen so far and you’ll find a line like this one:
const [count, setCount] = useState(0);That return is a tuple: two slots, fixed length, a different type per position, with a number at index 0 and a setter function at index 1. The caller destructures it and immediately renames both halves to whatever the local component cares about. So you get count and setCount here, isOpen and setIsOpen in the next file, email and setEmail in the form below that.
That raises a question worth holding in mind for the rest of this lesson: why does useState return a tuple instead of a named-field object like { value, setValue }? The rest of the lesson works toward the answer.
A tuple is what TypeScript calls an array shape where position matters and length is fixed. You already know arrays from the chapter on picking the right container, and you already know destructuring from the chapter on functions, naming, and control flow. A tuple is the type-level rule that turns “this array has some elements” into “this array has exactly these elements, in this order, with these types.” This lesson covers three things: the syntax (next section), the element labels that turn position into documentation, and the decision rule for when a tuple beats a named-field object.
Tuples also come with a failure mode worth seeing up front, because position is fragile. Say a function returns [id, name, email, role, createdAt], five strings and a Date. Six months later a teammate inserts lastSeenAt at position 3. Every call site that destructured by position silently shifts: role now holds an email, and email now holds a role. A downstream role.includes('admin') then either crashes or, more quietly, lets the wrong user pass an authorization check. Two things guard against this, and you’ll meet both below. Labels, a TypeScript 4.0 feature this lesson writes by default, document each position so the tooling surfaces it. The three-position rule keeps the problem from arising in the first place.
Tuple syntax: positions, types, and the array contrast
Section titled “Tuple syntax: positions, types, and the array contrast”A tuple type is [T1, T2, T3, ...]: square brackets with one type per position. The annotated value is a plain array at runtime, so Array.isArray(tup) is true, and .map and .length work the way you’d expect. The tuple part lives only at the type level, where TypeScript pins the length and the type at each position.
The key shape to learn is the contrast between a tuple and an array that looks identical at the bracket level.
const pair: [string, number] = ['draft', 3];
const wrongType: [string, number] = ['draft', 'three'];const wrongLength: [string, number] = ['draft', 3, true];const wrongOrder: [string, number] = [3, 'draft'];Length is fixed at 2. Position 0 must be a string, position 1 must be a number. The first line is the only one that compiles. The three below it each error in turn: wrong type at a position, wrong length, then wrong order. The compiler reads the shape strictly.
const a: (string | number)[] = ['draft', 'three'];const b: (string | number)[] = ['draft', 3, 5];const c: (string | number)[] = [3, 'draft'];Every value the tuple rejected is fine here: any length, any mix of the two element types, any order. This is what you write when length is unknown and the slots are interchangeable. The two forms look similar at the bracket level, but the type-level constraint is different.
At runtime, both forms are plain arrays. TypeScript erases the tuple constraint at build time, exactly like it erases every other type. The tuple is purely a compile-time agreement: “this array has exactly two elements, this type at position 0, this type at position 1.” Nothing at runtime knows the difference.
Element labels: position with documentation
Section titled “Element labels: position with documentation”Return to the bug from the introduction. getUserRow returns five strings and a Date. Without labels, every call site has to remember the order from outside the signature, and TypeScript can’t catch a swap, because every wrong order still type-checks.
const getUserRow = (userId: string): [string, string, string, string, Date] => { // ...};
const [id, name, role, email, createdAt] = getUserRow('u_1');role and email are swapped at the destructure. The compiler accepts it, because every position is the same type, so any permutation passes the check. The bug surfaces three files away when role.includes('admin') reads an @ and silently fails to match anything.
const getUserRow = (userId: string): [ id: string, name: string, email: string, role: string, createdAt: Date,] => { // ...};
const [id, name, email, role, createdAt] = getUserRow('u_1');Each position carries a name at the type level. The names show up in editor tooltips when you hover the call, and autocomplete suggests them in order at the destructure site. The compiler still can’t enforce that you pick the matching local names, but the labels turn position into documentation that lives where the value lives.
A labeled tuple is exactly that: a tuple where each position has a name. The syntax is [name: type, name: type, ...]. Two rules go with it.
Label all positions or none. [a: string, number] is a syntax error. TypeScript won’t let you mix the two forms inside one tuple.
Past length 2, label by default. The two-element case, such as [value, setter], [key, value], or [error, result], is conventional enough that labels are noise. Past two, an unlabeled tuple is the kind of thing the next refactor breaks, since nobody at the call site can see which position means what. Three is the threshold where the cost of remembering position outweighs the cost of writing the names.
One thing labels do not do is change type compatibility. As far as assignability is concerned, [string, number] and [a: string, b: number] are the same type. The labels are pure documentation that the editor surfaces. That is by design: labels are a tooling feature, not a type-system feature.
Readonly tuples
Section titled “Readonly tuples”Prefix a tuple type with readonly and you get the same behavior as readonly T[] from the previous lesson: the binding is locked at the type level, and the array methods that would mutate the tuple (.push, .pop, .splice, .sort, .reverse, and direct index-write) drop off the type’s surface.
type Pair = readonly [string, number];
const p: Pair = ['draft', 3];
p[0] = 'sent';p.push('extra');Both of the last two lines error. p[0] = 'sent' is the index-write you can’t do on a readonly tuple. p.push('extra') is the array-mutation method that doesn’t exist on the readonly form. Read methods (.map, .filter, .length, and indexed read) stay intact, exactly like readonly T[]. A function that takes a readonly [string, number] is promising not to mutate the tuple in place.
One related form is worth seeing once, because you’ll meet it in real code and want to recognize it: as const on an inline array literal produces a readonly tuple of literal types.
const statuses = ['draft', 'sent', 'paid'] as const;// ^? readonly ['draft', 'sent', 'paid']Without as const, the inferred type of that array literal is string[], because TypeScript widens to a mutable array as the safer default for the average case. With as const, the same literal becomes a readonly tuple where each element keeps its narrow literal type. This is the starting point for the typed-config pattern that a later lesson in this chapter, Keeping literals narrow, builds out, but you don’t need to reach for it yet.
Optional and rest positions
Section titled “Optional and rest positions”Two more pieces of syntax exist for the cases where the tuple’s length is almost fixed. You won’t reach for either much in application code, but you’ll meet them in library types and need to read them correctly.
type RangeOrPoint = [start: number, end?: number];const point: RangeOrPoint = [3];const range: RangeOrPoint = [3, 7];
type Message = [header: string, ...payload: number[]];const ping: Message = ['ping'];const burst: Message = ['ping', 1, 2, 3];Optional positions use ? after the label (or after the type when there’s no label): [start: number, end?: number] accepts a length-1 or length-2 tuple. The same rule for optional fields from the previous lesson applies, so optional positions must follow required ones. A required slot can’t sit after an optional slot.
Rest positions use ...T[] for “any number of additional elements of this type”: [header: string, ...payload: number[]] is one string followed by any number of numbers. A tuple has at most one rest position. Since TypeScript 4.2 the rest doesn’t have to be last ([string, ...number[], boolean] is legal), but the fixed-then-rest form is the standard shape because it destructures cleanly: const [header, ...payload] = m. Other arrangements surface in library types; recognize them, but don’t reach for them in your own application code.
The three sites where tuples earn their weight
Section titled “The three sites where tuples earn their weight”Now we can answer the useState question. A tuple earns its weight when the caller will destructure-and-rename on every use. The rename happens at the destructure, so the positional shape carries no naming penalty: the caller writes the names they want, and the function ships positions. Three sites in 2026 SaaS code fit this pattern, and almost everything else is better as an object.
Custom hook returns
Section titled “Custom hook returns”A hook that returns ordered state plus an action returns a tuple. useState’s [value, setter] is the precedent, and the project’s hook conventions follow it for any hook with two slots the caller will rename. Here’s the standard pattern:
const useToggle = (initial = false): readonly [boolean, () => void] => { const [on, setOn] = useState(initial); const toggle = () => setOn((v) => !v); return [on, toggle] as const;};
const [isOpen, toggleOpen] = useToggle();const [isHovered, toggleHover] = useToggle();Two callers get two completely different names, isOpen/toggleOpen and isHovered/toggleHover, from the same hook. The tuple shape lets the call site’s naming be the API. If useToggle returned { on, toggle } instead, each caller would either use the literal names (a collision the moment two toggles live in one component) or rename at the destructure (const { on: isOpen, toggle: toggleOpen } = useToggle()), which is strictly more verbose for the same outcome.
A destructure-and-rename is the operation that makes the tuple’s positional shape work. Without it, the tuple shape is a worse object. With it, the tuple is the right call.
One ceiling rule: two elements is the comfortable maximum for a tuple hook return. Past two, names win, which is why useForm() returns { register, handleSubmit, formState, ... } rather than a six-tuple. Three is the boundary where labels stop being enough and the better choice is an object. You’ll meet this rule again later in the course when you write your own hooks for the project.
Object.entries and Map iteration
Section titled “Object.entries and Map iteration”Object.entries(obj) returns an array of [key, value] tuples. map.entries() returns the same pair shape over a Map, and so do array.entries() and set.entries(). The pattern is everywhere in the language, and recognizing the tuple is what lets the destructure read cleanly:
const statusLabels = { draft: 'Draft', sent: 'Sent', paid: 'Paid' };
for (const [status, label] of Object.entries(statusLabels)) { console.log(`${status}: ${label}`);}
const counts = new Map<string, number>([ ['draft', 3], ['sent', 7],]);
for (const [status, count] of counts.entries()) { console.log(`${status}: ${count}`);}The destructure in the for...of head names both halves of the pair without an intermediate variable. You should come away able to read for (const [a, b] of x.entries()) anywhere, since the language ships this shape across Object, Map, Set, and Array. The two-slot tuple is exactly the case where labels would be noise, and the rename-at-the-call-site rule earns its weight: the same Object.entries call gets [status, label] in one loop and [status, count] in another.
Go-style [error, value] result wrappers
Section titled “Go-style [error, value] result wrappers”A third pattern you’ll meet in libraries is one you should read but not write for new code. Some error-as-value libraries return a [error, value] tuple where the caller destructures both halves and branches on the error:
const [err, user] = await tryFetch('/api/me');if (err) { return notFound();}The form reads cleanly when the caller knows the convention: error at position 0, value at position 1, exactly one of them set. You’ll see it in neverthrow, in hand-rolled tryCatch helpers, and in fetch wrappers from older codebases. Recognize it and destructure it correctly.
For new code, the course prefers a discriminated Result type:
type Result<T> = { ok: true; value: T } | { ok: false; error: Error };This shape appears later in this chapter (in the lesson on unions and intersections) as a seed, and gets built out in the chapter on errors-as-values. It scales past two slots, narrows cleanly on result.ok, and gives the consumer a name for the field it’s reading. The Go-style tuple is the case you read; the discriminated Result is the case you write.
When to reach for an object instead
Section titled “When to reach for an object instead”You’ve seen the three sites. Here is the rule for everything else: if the call site won’t destructure-and-rename, or if the positions are easy to swap by accident, use a named-field object. That rules out most “return multiple values” cases, since a function returning { id, email, role } is an object because the caller uses the names as-is. It also rules out tuples past three elements, where the cost of remembering position outweighs the conciseness gain.
Two short rules sit under that one.
Three positions is the rough boundary. Coordinates ([x, y, z]) survive: the names at the call site match the convention names, labels would be noise, and swapping is unlikely because the meanings are positional in the domain. Past three, the cost of remembering positions outweighs the conciseness, and the right choice is an object every time.
If the slots have different meanings to different callers, it’s an object. A single shape that ships [id, name, email] to a user-card component and [id, email, role] to an auth check is two different signatures masquerading as one tuple. Two functions need two shapes: usually objects, possibly two tuples, never one.
Try the decision once on your own:
Which return shape is best modeled as a tuple instead of an object?
getUserCard(id) returning { id, name, avatarUrl, role, createdAt } — five fields, callers use the names directly.useToggle() returning [boolean, () => void] — two slots, every caller destructures-and-renames.getCoordinates() returning [number, number, number, number, number] — five same-typed values with no domain hint.parseDate(s) returning { ok, value, error } — three fields, callers read by name.useToggle is exactly that — two slots, every caller renames at the destructure. The user-card shape has callers reading by name, so it’s an object. The five-number “coordinates” tuple has too many same-typed positions to swap safely; past three, an object wins. The parseDate shape is the discriminated Result from later in this chapter — a different pattern again.Write a labeled readonly tuple return
Section titled “Write a labeled readonly tuple return”Now write the shape once by hand. The starter below defines a useDisclosure hook that returns three slots, an isOpen boolean, an open action, and a close action, but the return type widens to (boolean | (() => void))[], the array shape, not the labeled tuple the caller needs.
Your job is to make the return type a labeled readonly tuple of [isOpen, open, close]. Two valid solutions exist. You can annotate the return type explicitly as readonly [isOpen: boolean, open: () => void, close: () => void], or you can add as const to the returned array literal and let inference recover the shape. Either path lands the same type, and the ^? query on Return below confirms it.
Type useDisclosure so its return is a labeled, readonly tuple of [isOpen, open, close]. The ^? query on Return should resolve to a type starting with `readonly [` — the readonly tuple form, with boolean at position 0 and () => void at positions 1 and 2. Two valid solutions: annotate the return type explicitly with labels (`readonly [isOpen: boolean, open: () => void, close: () => void]`), or add `as const` to the returned array literal and let inference do the rest. (The useState stub mirrors React's signature so the exercise stays type-only.)
-
Type query at line 13 must resolve to a type containing
readonly [
The two paths land the same type but read differently. The explicit annotation is the better choice when the hook is exported from a shared file: the contract is visible in the signature without opening the body, and a teammate reading the export sees the labels in the tooltip. The as const form is fine for hooks scoped to a single file, where the body is the documentation. Both are correct and both ship. The as const form comes back in full later in this chapter, in the lesson on keeping literals narrow.
External resources
Section titled “External resources”Official treatment of tuple types, including fixed, optional, and rest positions. The reference for the syntax in this lesson.
The release-notes entry that introduced the label syntax. Short, authoritative, and the canonical reference for why labels exist.
Matt Pocock's walkthrough of the as const path used in this lesson's exercise — same two solutions, applied to a custom hook return.
A walkthrough of why a custom hook's array return widens by default, and the explicit-annotation and as const fixes the lesson uses.