TypeScript Tuples

September 1, 202612 min read
Requirements:
ArraysFunctions| Unions

A tuple is an array where the length is fixed and every position has its own type. That one sentence covers most of what you need day to day, but tuples get a lot more interesting once you start using them in function signatures and type-level code.

Does JavaScript have tuples?

No. JavaScript has exactly one sequential data structure — the array — and it does not care how long yours is or what you put in it. There is no Tuple constructor, no tuple literal syntax, and no way to ask at runtime whether an array was meant to be a tuple.

The tuple is a TypeScript-only feature, and it is purely a compile-time contract over a plain array. Nothing survives compilation: no Tuple class, no length check, no emitted code at all. The type disappears and you are left with the array you always had.

const runtimePair: [string, number] = ['GET', 200]
 
// Everything you can actually ask at runtime:
Array.isArray(runtimePair) // true — the same answer as for any array
typeof runtimePair // 'object' — the same answer as for any array
runtimePair.length // 2, but only because there happen to be two items in it

That is the whole trick, and it is worth internalising early. Every guarantee a tuple gives you is enforced by the compiler and nowhere else. Parse some JSON into a [string, number] and TypeScript will believe you — it has no way to check.

Faking a tuple in plain JavaScript

Without TypeScript you use an array and lean on convention. Destructuring at the call site is what makes it readable, and Object.freeze is what stops anyone growing it.

// Plain JavaScript: an array standing in for a tuple
const jsPair = Object.freeze(['status', 200])
const [jsLabel, jsCode] = jsPair
 
console.log(jsLabel, jsCode)

Object.freeze is shallow, so it locks the two slots but not anything nested inside them. Freeze an array of objects and the objects are still mutable. It also fails silently in sloppy mode — the assignment is simply ignored rather than throwing — which is a good reason to keep 'use strict' or ES modules on.

You do not have to give up type checking to stay in .js files, either. Turn on checkJs and a JSDoc annotation gets you the real tuple type:

/** @type {[string, number]} */
const checkedPair = ['started', 500]
 
// TypeScript now treats this as a tuple, in a .js file:
// checkedPair.push('oops')  ❌ too long
// checkedPair[2]            ❌ no element at index 2

This is the cheapest way to get tuple safety into an existing JavaScript codebase. No rename, no build step change, one comment.

The Records and Tuples proposal, and what replaced it

If you came looking for a language-level JavaScript tuple, there was a serious attempt at one. The Records and Tuples proposal would have added two new deeply immutable primitives with their own syntax — #[1, 2] for a tuple and #{ x: 1 } for a record — comparable with === by value rather than by identity.

It is not coming. TC39 reached consensus to withdraw it at the April 2025 plenary, after it had sat at stage 2 for years without a path forward. Adding new primitives to the language turned out to drag in too much: equality semantics, typeof, and every engine's object model all had to move at once.

The follow-up is the Composites proposal, which chases the same use case with ordinary frozen objects instead of new primitives — Composite({ x: 1, y: 4 }) gives you value-based equality good enough to use as a Map or Set key. It is at stage 1 and has not shipped in any engine, so treat it as something to watch rather than something to plan around.

For production code today, the answer is unchanged: TypeScript's tuple type, or an array and a convention.

Arrays say "how", tuples say "how many"

An array type tells TypeScript what kind of thing is inside. A tuple type also tells it how many things there are and what order they come in.

// An array: any number of strings, all the same type
const languages: string[] = ['TypeScript', 'JavaScript', 'Go']
 
// A tuple: exactly two elements, each position with its own type
const version: [string, number] = ['TypeScript', 5.9]

That contract is what makes it useful. An array of string | number forces you to check the type of every element you touch. A tuple already knows that position 0 is the method and position 1 is the status code, so you can just use them. Get the length, the order, or the index wrong and the compiler stops you.

type HttpPair = [string, number]
 
const okPair: HttpPair = ['GET', 200] // ✅
const okMethod = okPair[0] // string
const okStatus = okPair[1] // number
 
// const tooLong: HttpPair = ['GET', 200, true]  // ❌ Source has 3 elements but target allows only 2
// const wrongOrder: HttpPair = [200, 'GET']     // ❌ Type 'number' is not assignable to type 'string'
// okPair[2]                                     // ❌ Tuple type 'HttpPair' has no element at index '2'

Reaching past the end is an error rather than a silent undefined, which is the part an array type can never give you.

Where you already meet tuples

You have probably used tuples without naming them. Object.entries() hands you [key, value] pairs, and each half keeps its own type through the destructure.

const config = { retries: 3, timeout: 500 }
 
for (const [configKey, configValue] of Object.entries(config)) {
  // configKey: string, configValue: number
  console.log(`${configKey} = ${configValue}`)
}

React's useState returns a [value, setter] tuple for the same reason: the caller names the parts, so it reads cleanly at the call site while staying fully typed.

The most common place this shows up in real code is Promise.all. Hand it a tuple of promises and you get a tuple of results back, each one keeping its own type.

async function loadDashboard(): Promise<void> {
  const results = await Promise.all([
    Promise.resolve('Ada'),
    Promise.resolve(42),
    Promise.resolve(true),
  ])
 
  const [userLabel, unreadCount, isAdmin] = results
  console.log(userLabel.toUpperCase(), unreadCount + 1, isAdmin)
}

Without tuple support this would come back as (string | number | boolean)[] and every line after the destructure would need a type guard. Tuples preserve information that an array type throws away.

TypeScript will not guess a tuple for you

This trips people up constantly. Write an array literal and TypeScript widens it to an array type, not a tuple, because that is the safer guess for something you might push to later.

// Inferred as (string | number)[], not [string, number]
const looseCoords = ['x', 10]
 
// Two ways to actually get a tuple
const annotatedCoords: [string, number] = ['x', 10]
const constCoords = ['x', 10] as const // readonly ['x', 10]

Annotate when you want a mutable tuple. Use as const when you want the narrowest thing possible — it freezes both the length and the literal values, so 'x' stays 'x' instead of widening to string. The rule of thumb: annotate at boundaries, as const for constants. Most "why is this (string | number)[]" confusion comes back to this one behaviour.

Labeled elements

[string, number] tells you the types and nothing else. Labels fix that.

type RangeLabeled = [start: number, end: number]
 
function describeRange(...range: RangeLabeled): string {
  const [start, end] = range
  return `${start}–${end}`
}
 
describeRange(1, 10)

Labels are documentation only — they change no types and disappear at runtime. What they change is the editor experience: hover hints show start and end instead of two anonymous numbers. On any tuple with more than two elements, label them.

Optional and readonly elements

A trailing element can be optional with ?.

type LogEntry = [message: string, code?: number]
 
const shortLog: LogEntry = ['started']
const fullLog: LogEntry = ['failed', 500]
 
const logCode = fullLog[1] ?? 0 // number | undefined, so give it a fallback

Optional elements have to come last, and reading one gives you number | undefined. That forces the narrowing step, which is the point.

Marking a tuple readonly locks it down completely — no reassignment, and none of the mutating array methods.

type FrozenPoint = readonly [number, number]
 
const startPoint: FrozenPoint = [0, 0]
// startPoint[0] = 5  // ❌ Cannot assign to '0' because it is a read-only property
// startPoint.push(1) // ❌ Property 'push' does not exist on type 'readonly [number, number]'

Reach for readonly on anything you pass around as a constant. Unlike Object.freeze, it costs nothing at runtime, because there is no runtime.

Rest elements and variadic tuples

A tuple does not have to be entirely fixed. A rest element soaks up "any number of these".

// One name, then any number of scores
type CommandArgs = [name: string, ...scores: number[]]
 
const cmdA: CommandArgs = ['sum']
const cmdB: CommandArgs = ['sum', 1, 2, 3]
 
// The rest element can sit in the middle
type Bookended = [first: string, ...middle: number[], last: boolean]
const bookendedValue: Bookended = ['a', 1, 2, true]

That middle-position rest element is the part people miss. It lets you type the common "required first argument, required callback last, whatever you like in between" signature exactly.

Combine rest elements with generics and you get variadic tuple types — tuples that are built from other tuples.

function prependId<T extends readonly unknown[]>(id: string, rest: T): [string, ...T] {
  return [id, ...rest]
}
 
const withIdResult = prependId('u1', [42, true])
// withIdResult: [string, number, boolean]

This is how wrapper functions keep their argument types intact instead of collapsing them into any[]. If you are writing a decorator, a middleware, or anything that forwards arguments, this pattern is the one to learn. Tuple Filter pushes it further, walking a tuple one element at a time and keeping only the parts that match, and Generics covers the type parameter side in more depth.

Reading types out of a tuple

Tuples are unusually good at the type level, because the compiler knows things about them it cannot know about arrays. The length of a tuple is a numeric literal type.

type RgbTuple = [number, number, number]
 
type RgbLength = RgbTuple['length'] // 3
type LooseLength = number[]['length'] // number
 
const rgbSize: RgbLength = 3
// const wrongSize: RgbLength = 4  // ❌ Type '4' is not assignable to type '3'

That single fact powers a surprising amount of type-level code. Try Length of Tuple — it is the shortest possible introduction to the idea.

Indexing with number instead of a literal gives you the union of everything inside.

type StatusTuple = ['idle', 'loading', 'done']
type StatusUnion = StatusTuple[number] // 'idle' | 'loading' | 'done'
 
const currentStatus: StatusUnion = 'loading'

This is the bridge between tuples and union types, and it is worth memorising. Going the other way — union back to tuple — is genuinely hard, which is why Union to Tuple sits in the hard tier. And because tuples are objects with numeric keys, you can map over them the way you map over an object: Tuple to Object is the cleanest demonstration, and Tuple to Nested Object extends the recursion further.

Tuples and function arguments are the same thing

A parameter list is a tuple. TypeScript treats them interchangeably, which means you can spread a tuple into a call and it type-checks position by position.

type DrawArgs = [x: number, y: number, color: string]
 
function drawDot(x: number, y: number, color: string): string {
  return `${color} dot at ${x},${y}`
}
 
const dotArgs: DrawArgs = [10, 20, 'red']
drawDot(...dotArgs)

The built-in Parameters<T> utility hands you that tuple directly, which is how you store a call and replay it later. Change the signature and every queued call breaks at compile time.

function saveUser(userName: string, age: number): void {
  console.log(userName, age)
}
 
type SaveUserArgs = Parameters<typeof saveUser> // [userName: string, age: number]
 
const queuedCall: SaveUserArgs = ['Ada', 36]
saveUser(...queuedCall)

See typeof for how typeof saveUser produces the function type in the first place.

When to use a tuple instead of an object

Tuples trade names for brevity, and that trade is only worth it when the meaning of each position is obvious without a name. [number, number] for coordinates is fine. A [string, string, number, boolean] is a puzzle at the call site, and the reader has to count commas to work out which string is which.

Three questions settle it. Does the order carry real meaning, like a [key, value] pair or a [value, setter] return? Will the caller destructure it immediately? Are there at most three elements? Three yeses means a tuple. Otherwise you want named fields — an interface or a type alias — which also lets you add a property later without breaking callers. Positional data is harder to evolve: adding a fourth element silently changes what index 3 means for anyone reading past the end.

The one pitfall to remember

A mutable tuple inherits the array methods, so you can grow it past its declared length and the type will not notice.

const mutablePair: [string, number] = ['a', 1]
mutablePair.push(2) // allowed — no error
// Three elements at runtime, still typed as two

push is typed against the union of the tuple's element types, so anything that fits any position gets in. Use readonly if that matters, which it usually does.

Wrapping up

JavaScript has no tuple, and after the Records and Tuples withdrawal it is not getting one any time soon. What you have is TypeScript's compile-time contract over a plain array — which, for the price of nothing at runtime, is most of what you wanted.

Annotate them or use as const, because inference will not produce one on its own. Label anything longer than a pair, mark constants readonly, and use rest elements when part of the shape is genuinely open-ended.

The type-level side is where they earn their keep: length as a literal, [number] to get a union, and variadic tuples to forward arguments without losing types. If you want that to stick, Construct Tuple and Permutations of Tuple will get you there faster than reading about it.

Share this article

Become a TypeScript Pro

Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges

Practice with Challenges

Put your typescript tuples knowledge to the test with these related challenges.

#18Length of Tuple
Easy
#11Tuple to Object
Easy
#7544Construct Tuple
Medium
#3188Tuple to Nested Object
Medium
#21220Permutations of Tuple
Medium
#730Union to Tuple
Hard
#399Tuple Filter
Hard

Related Concepts

Concepts that build on or relate to typescript tuples.

Union TypesTypeScript GenericsTypeScript typeofMapped Types