TypeScript Tuples
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 itThat 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 2This 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 fallbackOptional 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 twopush 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.
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