TypeScript Tuples

August 29, 202610 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.

They are also one of the few TypeScript features with no JavaScript counterpart at all. There is no tuple in the language you ship — only arrays that the compiler has agreed to treat more strictly. Once that clicks, both the power and the limits make sense.

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]

At runtime both are plain JavaScript arrays. There is no Tuple class and nothing extra gets emitted. The whole feature is a compile-time contract about shape.

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 or the order wrong and the compiler stops you.

type HttpPair = [string, number]
 
const okPair: HttpPair = ['GET', 200] // ✅
// 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'

Indexing works the way you would hope. Each position keeps its own type, and reaching past the end is an error rather than a silent undefined.

const entry: [string, number] = ['retries', 3]
 
const entryKey = entry[0] // string
const entryCount = entry[1] // number
// entry[2]  // ❌ Tuple type '[string, number]' has no element at index '2'

Where you already meet tuples

You have probably used tuples without naming them. Object.entries() hands you [key, value] pairs. React's useState returns a value and a setter. Any function that wants to return two related things and cannot be bothered with an object returns a tuple.

function useCounter(initial: number): [number, (next: number) => void] {
  let value = initial
  const setValue = (next: number) => {
    value = next
  }
  return [value, setValue]
}
 
const [count, setCount] = useCounter(0)
setCount(count + 1)

The payoff is destructuring. Because the caller names the parts, a tuple return reads cleanly at the call site while still being 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. That is a good way to feel what tuples buy you: they 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. If you are returning a tuple from a function, put the tuple type in the return annotation and let the body stay plain; the annotation does the work and the code stays readable.

The rule of thumb: annotate at boundaries, as const for constants.

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 do change is the editor experience: hover hints and autocomplete 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]
 
function readCode(logEntry: LogEntry): number {
  // The code may be missing, so give it a fallback
  return logEntry[1] ?? 0
}

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. It costs nothing and rules out a whole class of accidental mutation.

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

Because tuples are objects with numeric keys, you can also map over them the way you map over an object. Tuple to Object is the cleanest demonstration of that, and Tuple to Nested Object extends the same 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.

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)

Change saveUser's signature and every queued call breaks at compile time. 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, which means an interface or a type alias — and you keep the ability to add a property later without breaking every caller.

Positional data is also harder to evolve. Adding a fourth element to a tuple silently changes what index 3 means for anyone who was reading past the end. Objects do not have that problem.

Pitfalls

push still works. 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

Use readonly if that matters, which it usually does.

Watch the inference. Any array literal without an annotation or as const is an array, not a tuple. Most "why is this (string | number)[]" confusion comes back to this.

Wrapping up

Tuples are fixed-length arrays with per-position types. 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

Related Concepts

Concepts that build on or relate to typescript tuples.

Union TypesTypeScript GenericsTypeScript typeofMapped Types