Skip to content
Chapter 4Lesson 3

Tuples — positions with labels

Learn TypeScript tuples, fixed-length position-typed arrays, and when one beats a named-field object.

You’ve been using a tuple every day without naming the shape. Open any React component you’ve seen and you’ll find a line like this:

const [count, setCount] = useState(0);

That return is a tuple: a fixed-length array typed by position, here a number at index 0 and a setter function at index 1. The caller destructures it and renames both halves per component, so you write count and setCount in one file, isOpen and setIsOpen in the next.

That raises a question worth holding onto: why does useState return a tuple instead of a named-field object like { value, setValue }?

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.”

Position is fragile, though. Say a function returns [id, name, email, role, createdAt], and a teammate later inserts lastSeenAt at position 3. Every call site that destructured by position silently shifts: role now holds an email. A downstream role.includes('admin') then crashes, or worse, lets the wrong user pass an authorization check. Labels on each position and a cap on tuple length, both covered below, guard against this.

A tuple type is [T1, T2, T3, ...]: square brackets with one type per position. The value is a plain array at runtime; the tuple part lives only at the type level, where TypeScript pins the length and the type at each position.

What’s worth learning 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 a number. Only the first line compiles. The three below it each break the shape in turn: wrong type, wrong length, wrong order.

Recall the swap bug from the intro: getUserRow returns five strings and a Date, so every position is the same type and any wrong order still type-checks. Without labels, each call site has to remember the order on its own, and TypeScript can’t catch a mistake.

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. Every position is the same type, so the compiler accepts any permutation. The bug surfaces three files away when role.includes('admin') reads an @ and silently matches nothing.

A labeled tuple gives each position a name, with the syntax [name: type, name: type, ...]. Two rules go with it.

Label all positions or none. [a: string, number] is a syntax error.

Past length 2, label by default. A two-element pair like [value, setter], [key, value], or [error, result] is conventional enough that labels are noise. Past two, nobody at the call site can see which position means what, so an unlabeled tuple is what the next refactor breaks.

Labels do not change type compatibility: [string, number] and [a: string, b: number] are the same type to the compiler. They are pure documentation that the editor surfaces.

Prefix a tuple type with readonly and you get the same behavior as readonly T[] from the previous lesson: the methods that would mutate the tuple (.push, .pop, .splice, .sort, .reverse, and direct index-write) disappear from the type.

type Pair = readonly [string, number];
const p: Pair = ['draft', 3];
p[0] = 'sent';
p.push('extra');

Both of the last two lines error: a readonly tuple has no index-write and no mutating methods. Read methods (.map, .filter, .length, and indexed read) stay intact, exactly like readonly T[]. A function that takes a readonly [string, number] promises not to mutate the tuple in place.

One related form is worth recognizing: 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, that literal is inferred as string[], since TypeScript widens to a mutable array by default. With as const, each element keeps its narrow literal type inside a readonly tuple. You don’t need to reach for it yet; the full pattern lands later in this chapter, in the lesson on keeping literals narrow.

Two more pieces of syntax cover tuples whose length is almost fixed. You’ll rarely write either, but you need to read them in library types.

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 type: [start: number, end?: number] accepts a length-1 or length-2 tuple. As with the optional fields from the previous lesson, optional positions must follow required ones.

Rest positions use ...T[] for any number of additional elements of that type: [header: string, ...payload: number[]] is one string followed by any number of numbers. A tuple has at most one rest position, and the readable shape puts it last so it destructures cleanly: const [header, ...payload] = m.

Now we can answer the useState question. A tuple earns its place when the caller destructures-and-renames on every use. The rename happens at the destructure, so position costs nothing in naming: the caller picks the names, the function ships positions. Three sites in 2026 code fit this pattern; almost everything else is better as an object.

A hook that returns ordered state plus an action returns a tuple, following useState’s [value, setter] precedent for any two-slot hook the caller will rename:

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 different names, isOpen/toggleOpen and isHovered/toggleHover, from the same hook: the tuple lets the call site’s naming be the API. If useToggle returned { on, toggle }, each caller would either reuse the literal names, which collide the moment two toggles share a component, or rename at the destructure (const { on: isOpen, toggle: toggleOpen } = useToggle()), which is more verbose for the same result.

A destructure-and-rename is what makes the positional shape work. Without it, the tuple is just a worse object; with it, the tuple is the right call.

Two elements is the comfortable ceiling for a hook return. Past two, names win, which is why useForm() returns { register, handleSubmit, formState, ... } rather than a six-tuple.

Object.entries(obj) returns an array of [key, value] tuples, and map.entries(), array.entries(), and set.entries() return the same pair shape. 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 with no intermediate variable. Once you can read for (const [a, b] of x.entries()), you can read it across Object, Map, Set, and Array. The two-slot pair is where labels would be noise and rename-at-the-call-site pays off: the same Object.entries call gives [status, label] in one loop and [status, count] in another.

A third pattern shows up in libraries and is one to read, not write. 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();
}

It reads cleanly once you know 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 older fetch wrappers. 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 };

It scales past two slots, narrows cleanly on result.ok, and gives the consumer a name for the field it reads. You’ll build it out later in the course. The Go-style tuple is what you read; the discriminated Result is what you write.

Outside those three sites, one rule covers the rest: 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: a function returning { id, email, role } is an object because the caller uses the names as-is.

Two cases sharpen the rule.

Three positions is the rough boundary. Coordinates ([x, y, z]) survive because the call-site names match the convention, labels would be noise, and the positions are fixed by the domain. Past three, the cost of remembering positions outweighs the conciseness, and an object wins every time.

If the slots mean different things to different callers, use 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 signatures masquerading as one tuple. Two callers need two shapes: usually objects, possibly two tuples, never one.

Try the rule yourself:

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.

The starter below defines a useDisclosure hook that returns an isOpen boolean, an open action, and a close action. But its return type widens to (boolean | (() => void))[], a plain array, not the labeled tuple the caller needs.

Make the return a labeled readonly tuple of [isOpen, open, close]. Two solutions land the same type: annotate the return explicitly as readonly [isOpen: boolean, open: () => void, close: () => void], or add as const to the returned array literal and let inference recover the shape.

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 [
Booting type-checker…

Prefer the explicit annotation when the hook is exported from a shared file: the contract and its labels show up in the signature and tooltip without opening the body. Reach for as const when the hook stays in one file, where the body is the documentation. The as const form returns in the lesson on keeping literals narrow.