Ch. 2 · TypeScript

TypeScript Tuples for Fixed Position Data

When a fixed-length array beats an object, why a returned [value, setter] pair needs a type or as const, and the push hole in tuples.

~5 min readintermediateupdated Oct 7, 2026

A tuple is an array whose length and per-position types are part of its type. [number, number] is “exactly two numbers”, and [string, number] is “a string, then a number”. JavaScript already uses this shape everywhere: React’s useState, Object.entries, Promise.all, CSV rows, coordinate pairs. TypeScript lets you say so. The interview questions are usually about inference (why your hook’s return type came out as an array of unions) and about choosing between a tuple and an object.

Positions with meaning

type LatLng = [lat: number, lng: number];

const office: LatLng = [51.5072, -0.1276];
const [lat, lng] = office;
const third = office[2];
const swapped: LatLng = [-0.1276];
TypeScript
tup1.ts(4,22): error TS2493: Tuple type 'LatLng' of length '2' has no element at index '2'.
tup1.ts(5,7): error TS2322: Type '[number]' is not assignable to type 'LatLng'.
  Source has 1 element(s) but target requires 2.
Text

Both errors are things a plain number[] would allow. The tuple knows there are exactly two elements, so index 2 is out of range, and a one-element array is too short. Destructuring gives each variable its position’s type.

The labels lat: and lng: were added in TypeScript 4.0. They show up in editor tooltips and in signature help, which makes [number, number] much less mysterious to the next reader. They don’t affect checking: [lat: number, lng: number] and [number, number] are the same type, and nothing stops a caller passing [lng, lat] in the wrong order. Both are numbers. That’s the core limitation of tuples, and the reason they suit some data and not others.

The [value, setter] inference trap

Custom React hooks often return a pair, following useState. Write one without a return type:

function useToggleInferred(initial: boolean) {
  let on = initial;
  return [on, () => { on = !on; }];
}

const [a, b] = useToggleInferred(false);
b();
TypeScript
tup1.ts(18,1): error TS2349: This expression is not callable.
  Not all constituents of type 'boolean | (() => void)' are callable.
    Type 'false' has no call signatures.
Text

An array literal is inferred as an array, not a tuple, because arrays are mutable and could grow. So the return type is (boolean | (() => void))[], and each destructured element has the union type. The caller can’t call the toggle without a type check, and can’t use the boolean as a boolean.

There are two fixes. Annotate the return type, which is the clearer choice for a public hook:

function useToggle(initial: boolean): [on: boolean, toggle: () => void] {
  let on = initial;
  return [on, () => { on = !on; }];
}
const [isOpen, toggleOpen] = useToggle(false);
TypeScript

Or add as const to the returned literal, which infers readonly [boolean, () => void]. With that version, b() compiles and a.toFixed() fails with Property 'toFixed' does not exist on type 'boolean'., which shows each position kept its own type. The annotation documents the API better; as const is quicker for internal helpers.

Optional and rest elements

Tuples can have optional trailing elements and a rest element:

type Range = [start: number, end?: number];
const r1: Range = [5]; // ok: end omitted

type Command = [name: string, ...args: string[]];
const cmd: Command = ["git", "commit", "-m", "msg"];
const bad: Command = [];
TypeScript
tup1.ts(24,7): error TS2322: Type '[]' is not assignable to type 'Command'.
  Source has 0 element(s) but target requires 1.
Text

Command says “at least one string, and the first one is the name”. That’s a precise description of process.argv-style data that a string[] can’t express. Rest elements can sit in the middle too ([first: string, ...middle: number[], last: boolean]), and variadic tuple types let generic functions add or remove elements, which is how libraries type helpers like bind and pipe.

Tuples you already use

The standard library returns tuples in more places than people notice, which is a good point to make in an interview:

const [user, orders] = await Promise.all([getUser(), getOrders()]);
// user: { id: string }, orders: number[]

const entries = Object.entries({ a: 1 }); // [string, number][]
TypeScript

Promise.all is typed so that an array literal of promises produces a tuple of their resolved values, in order. That’s why destructuring its result gives each variable the right type instead of a union. Object.entries returns an array of [key, value] pairs, and new Map(entries) accepts exactly that shape.

The push hole

Here’s a gap worth knowing. A mutable tuple type is still an array type underneath, and it keeps the array methods:

const pt: LatLng = [1, 2];
pt.push(3);           // compiles
console.log(pt.length);
TypeScript

Running the same code prints 3. TypeScript still believes pt has two elements, so pt.length is typed as the literal 2, which is now false. You can see the mismatch directly: const len: 3 = pt.length fails with Type '2' is not assignable to type '3'. even though the runtime value is 3. Code that loops for (let i = 0; i < pt.length; i++) would now visit an element the type says doesn’t exist. Index access is checked, but methods that change length aren’t. If the length must be fixed, make the tuple readonly:

const fixed: readonly [number, number] = [1, 2];
fixed.push(3);
TypeScript
tup1.ts(30,7): error TS2339: Property 'push' does not exist on type 'readonly [number, number]'.
Text

For coordinates, RGB values and other fixed-shape data, readonly tuples are the safer default. They’re also what as const produces, so the two approaches line up.

Tuple or object?

Tuples are a good fit when the order is conventional and widely understood, when the caller usually destructures and names the parts themselves, and when the data is short-lived. The useState pattern works because callers write const [count, setCount] = ... and pick names that suit their component. If you returned { value, setValue }, every caller would have to rename with { value: count, setValue: setCount }.

Objects win when there are more than two or three positions, when values of the same type could be swapped, or when the data travels far from where it was created. [number, number, number, boolean] tells a reader nothing; { width, height, depth, fragile } tells them everything, and nobody can mix up width and height by accident. A rule that works well: if you’d need a comment to explain the order, use an object.

Interview practice

Write the type for a useFetch(url) hook that returns [data, error, isLoading], where data is T | undefined and error is Error | undefined. Then a reviewer suggests returning an object instead. Argue for one of them.

Answer

function useFetch<T>(url: string): readonly [data: T | undefined, error: Error | undefined, isLoading: boolean] {
  // implementation omitted
  return [undefined, undefined, true] as const;
}
TypeScript

The labels make the positions readable in tooltips, readonly stops callers pushing onto the result, and the explicit return type prevents the union-array inference you’d get from a bare array literal.

I’d still side with the reviewer here. Three positions is past the point where order is obvious, and a caller who only needs isLoading has to write const [, , isLoading] = useFetch(url), which is easy to get wrong. More importantly, the tuple allows combinations that make no sense together, such as data and an error at the same time. Returning a discriminated union object, { status: "loading" } | { status: "error"; error: Error } | { status: "success"; data: T }, lets callers destructure only what they need and lets the compiler stop them reading data before checking the status. Tuples are best for two-element “value plus action” pairs like useState; for anything that describes state, I’d use an object.

Further reading

More in TypeScript

read ✓TypeScript · mid

TypeScript Mapped Types and Modifiers

How { [K in keyof T]: T[K] } rebuilds a type, how ?, readonly, -? and -readonly change it, and why Partial stops one level down.

~5 min readread →
esc