Skip to main content

Tuple types in TypeScript

Tuple types in TypeScript are fixed-length arrays where each position has its own specific type.

Theory

TL;DR

  • A tuple is a typed array where position matters: [string, number] means index 0 is always string, index 1 is always number
  • Unlike a regular array, the compiler enforces both the count and the type at each slot
  • useState in React returns a tuple: [value, setter]
  • Named elements ([x: number, y: number]) add readability without changing runtime behavior
  • Use tuples for function return values and structured pairs, not for lists of unknown length

Quick example

typescript
// Array: any number of strings, all the same type const tags: string[] = ["ts", "react", "node"]; // Tuple: exactly 2 elements, a specific type per position const user: [string, number] = ["Alice", 25]; user[0]; // string user[1]; // number user[2]; // Error: Tuple type '[string, number]' has no element at index '2' // TypeScript catches this at compile time, not runtime const wrong: [string, number] = [25, "Alice"]; // Error

The compiler knows the exact type at each index. user[0] is string, not string | number.

Tuple vs Array

A regular array (string[]) says: every element is a string, any count. A tuple ([string, number]) says: exactly two elements, first is string, second is number. That single difference changes how the compiler reasons about each slot.

With a plain array, arr[0] returns string. With a tuple, tup[0] also returns string, but tup[1] returns number. The compiler tracks each position independently.

Named elements

typescript
type Point = [x: number, y: number]; type Range = [start: number, end: number]; type HttpResponse = [status: number, body: string]; const origin: Point = [0, 0];

Names exist for humans, not the compiler. Point and [number, number] are structurally identical. But destructuring const [x, y] = origin will show x and y in IDE tooltips. Worth adding on any tuple with 3 or more elements.

Optional and rest elements

typescript
// Optional element (must be last) type Response = [number, string, string?]; const minimal: Response = [200, "OK"]; const full: Response = [200, "OK", "application/json"]; // Rest elements: first is string, then any number of numbers type StringThenNumbers = [string, ...number[]]; const scores: StringThenNumbers = ["player1", 90, 85, 92]; // Fixed ends, flexible middle type Padded = [string, ...number[], boolean]; const row: Padded = ["label", 1, 2, 3, true];

Optional elements must come last. Only one rest element per tuple, but it can sit in the middle.

Readonly tuples

typescript
type ReadonlyPoint = readonly [number, number]; const p: ReadonlyPoint = [10, 20]; p[0] = 30; // Error: Cannot assign to '0' because it is a read-only property // as const creates a readonly tuple with literal types const coords = [10, 20] as const; // readonly [10, 20]

as const goes further than readonly: it narrows the types to literal values (10 and 20, not number). That matters when a function signature expects readonly [10, 20] specifically vs just readonly [number, number].

How the compiler handles tuples

TypeScript compiles tuples to plain JavaScript arrays. There is no tuple object at runtime. Length checks, per-index types, readonly constraints: all of that lives only in the type checker.

Because of this, calling .push() on a non-readonly tuple compiles without error in some cases. The compiler checks index access but array mutation methods can pass through. Use readonly to block them.

Common mistakes

Forgetting to annotate, getting an inferred union array:

typescript
const pair = ["Alice", 25]; // TypeScript infers: (string | number)[] — not a tuple! function greet(person: [string, number]) {} greet(pair); // Error: Argument of type '(string | number)[]' is not // assignable to parameter of type '[string, number]' // Fix: annotate explicitly const pair: [string, number] = ["Alice", 25];

Mutating a non-readonly tuple:

typescript
const coords: [number, number] = [10, 20]; coords.push(30); // Compiles fine, breaks the length contract at runtime const safe: readonly [number, number] = [10, 20]; safe.push(30); // Error: Property 'push' does not exist on type 'readonly [number, number]'

Mixing up as const and explicit annotation:

typescript
const a = [1, "hello"] as const; // readonly [1, "hello"] — literal types const b: [number, string] = [1, "hello"]; // [number, string] — wider types // a[0] is type '1', b[0] is type 'number'

Use as const when you need literal types. Use explicit annotation when you need the wider type.

Optional element in the wrong position:

typescript
type Wrong = [string, number?, boolean]; // Error: A required element cannot follow an optional element type Right = [string, boolean, number?]; // OK

Real-world usage

  • React useState returns [S, Dispatch<SetStateAction<S>>], a tuple
  • useReducer returns [state, dispatch]
  • Go-style error returns: function fetchUser(): Promise<[User | null, Error | null]>
  • D3 and charting libraries use [number, number] for coordinate pairs
  • Variadic tuple types power typed compose and pipe in functional libraries

Follow-up questions

Q: Why does TypeScript infer (string | number)[] instead of [string, number] when I write const x = ["Alice", 25]?
A: TypeScript widens array literals to the most general type because arrays are mutable and can change length. To get a tuple, annotate explicitly or use as const.

Q: What is a variadic tuple type?
A: Variadic tuples use spread in type position: type Concat<A extends unknown[], B extends unknown[]> = [...A, ...B]. This lets you build new tuple types by combining others. Libraries use it for typed compose, pipe, and middleware chains where the full argument list needs to be tracked across function boundaries.

Q: Can tuples extend other tuples?
A: There is no extends for tuples the way classes have it. But you can spread one into another: type Extended = [...Base, string]. That is how variadic tuples work.

Q: What happens when you call .push() on a readonly tuple?
A: TypeScript errors: Property 'push' does not exist on type 'readonly [number, number]'. On a non-readonly tuple, .push() compiles but breaks the length contract at runtime.

Q: How does destructuring a tuple differ from destructuring an object?
A: With tuples, names come from the destructuring pattern, not the type. const [a, b] = point works regardless of named elements. With objects, property names are part of the type contract itself.

Examples

Parsing and returning coordinate pairs

typescript
function parseLatLng(input: string): [number, number] | null { const parts = input.split(",").map(Number); if (parts.length !== 2 || parts.some(isNaN)) return null; return [parts[0], parts[1]]; } const result = parseLatLng("48.8566,2.3522"); if (result) { const [lat, lng] = result; console.log(`Paris: ${lat}, ${lng}`); }

A tuple works better than an object here because the relationship is purely positional and the pair will never grow additional fields. The call site destructures naturally.

Go-style error handling

typescript
async function fetchUser(id: string): Promise<[User | null, Error | null]> { try { const user = await db.users.findById(id); return [user, null]; } catch (err) { return [null, err instanceof Error ? err : new Error(String(err))]; } } const [user, error] = await fetchUser("123"); if (error) { console.error(error.message); return; } // TypeScript knows user is not null at this point console.log(user.name);

This pattern makes error handling visible at the call site without wrapping every call in try-catch. The convention teams agree on: either the first or the second element is null, never both. TypeScript does not enforce that rule automatically.

Typed event system with variadic tuples

typescript
type EventMap = { resize: [width: number, height: number]; keydown: [key: string, modifiers: string[]]; submit: [data: FormData]; }; function emit<K extends keyof EventMap>(event: K, ...args: EventMap[K]): void { // dispatch args to registered listeners } emit("resize", 1920, 1080); // OK emit("keydown", "Enter", ["ctrl"]); // OK emit("resize", "wide", 1080); // Error: 'string' not assignable to 'number'

The event name and its argument types are linked in one place. Add a new event to the map and the compiler checks every emit call across the codebase automatically. This is where tuple types earn their place in a large project.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?