Skip to main content

TypeScript Tuples

You've probably written a function that returns two or three related values and reached for an object -- or worse, a loosely-typed array where position zero is a number, position one is a string, and you just have to remember which is which. TypeScript tuples solve that problem. They let you define a fixed-length array where each position has its own type, so the compiler knows exactly what's at index 0, index 1, and beyond.

If you've used React's useState, you've already worked with tuples. What makes them worth mastering goes well beyond that, though -- labeled elements, variadic patterns, and typed multi-value returns all lean on the same foundation.

Defining a Tuple Type in TypeScript

To define a tuple in TypeScript, use this syntax:

let point: [number, number] = [10, 20];

Here, point is a tuple with two number elements. The type annotation [number, number] tells TypeScript that this variable must contain exactly two numbers in that specific order.

Unlike regular TypeScript arrays, tuples have a fixed length and each position carries its own type. That makes them a natural fit for coordinates, key-value pairs, function return values -- anywhere a group of related but differently-typed values belong together.

For example, you might use a tuple to store a person's name and age:

let person: [string, number] = ["Alice", 30];

You can also use tuples with TypeScript generics to create reusable tuple types:

type Pair<T, U> = [T, U];
let coordinates: Pair<number, number> = [10, 20];
let nameAndAge: Pair<string, number> = ["Bob", 25];

Named Tuple Elements in TypeScript

TypeScript 4.0 introduced labeled (named) tuple elements, and it's one of those features that makes your code dramatically easier to read at a glance.

Without labels, a tuple like [number, number, boolean] forces you to remember what each position means. With labels, you get self-documenting types and better IDE tooling:

// Without labels -- what does each position mean?
type UserRecord = [string, number, boolean];

// With labels -- immediately clear
type UserRecord = [name: string, age: number, isAdmin: boolean];

Here's how named tuples work in practice:

type Geolocation = [lat: number, lng: number, altitude?: number];

function getCurrentPosition(): Geolocation {
return [51.5074, -0.1278]; // latitude, longitude -- TypeScript knows exactly what these are
}

const [lat, lng, alt] = getCurrentPosition();

Labels don't change how destructuring works -- you can still name your destructured variables whatever you like. But they show up in autocomplete and hover hints in your editor, which is a real quality-of-life improvement when you're working with unfamiliar code.

One rule to keep in mind: if you label one element, you have to label all of them. Optional elements get the ? on the label, not the type:

// Correct
type ApiResult = [status: number, body: string, cached?: boolean];

// Error: can't mix labeled and unlabeled
// type ApiResult = [number, body: string];

Tuples vs Arrays: Picking the Right Tool

Knowing when to reach for a tuple vs a plain array is where a lot of developers get tripped up. Here's the practical breakdown:

TupleArray
LengthFixedVariable
Element typesDifferent per positionSame throughout
Use caseStructured, ordered dataCollections of similar items
Destructuring intentPosition has meaningIteration is common
Type safetyEnforced per positionEnforced for element type

Use a tuple when:

  • Each position has a distinct, fixed meaning (coordinates, RGB values, status/value pairs)
  • You're returning multiple values from a function and want typed destructuring
  • You need compile-time guarantees about both length and per-position types

Use an array when:

  • You have a collection of the same type of thing (a list of users, a list of IDs)
  • The length varies at runtime
  • You'll mostly iterate over the elements rather than access them by position

A common real-world heuristic: if you'd document each index separately ("index 0 is the status code, index 1 is the message"), that's a tuple. If you'd just say "a list of strings", that's an array.

For key-value structured data with named properties, a TypeScript Record type is often a better fit than a tuple -- records give you named access instead of positional access.

Using Tuples in Function Parameters and Return Types

Tuples work well with functions that need to return multiple values. Unlike some languages that have built-in support for multiple return values, TypeScript uses tuples to achieve this functionality.

function calculateDistance(point: [number, number]): [number, number, string] {
const x = point[0] + 10;
const y = point[1] + 20;
const description = `New position: (${x}, ${y})`;
return [x, y, description];
}

const [newX, newY, description] = calculateDistance([10, 20]);
console.log(newX); // Output: 20
console.log(newY); // Output: 40
console.log(description); // Output: "New position: (20, 40)"

Returning a tuple instead of an object works well for small, ordered results where destructuring is the expected usage. The return type [number, number, string] tells callers exactly what they'll get at each position.

The most common real-world example of this pattern is React's useState hook. Every time you write const [count, setCount] = useState(0), you're working with a tuple -- useState returns [S, Dispatch<SetStateAction<S>>] where the first element is the state value and the second is the setter function. TypeScript knows exactly what you'll get at each position, which is why you get full type safety on both count and setCount without any extra annotations.

You can use named tuples to make your own hooks and utilities just as ergonomic:

// Custom hook returning a named tuple
function useToggle(initial: boolean): [value: boolean, toggle: () => void] {
const [value, setValue] = useState(initial);
const toggle = () => setValue(v => !v);
return [value, toggle];
}

const [isOpen, toggleOpen] = useToggle(false);

Destructuring tuple return values works well with TypeScript utility types like Readonly<T>, or when you need to apply type assertion to what came back.

Accessing and Modifying Elements within a TypeScript Tuple

You can access and modify elements in a tuple using array-like indexing. TypeScript enforces type checking based on the defined tuple structure:

let point: [number, number] = [10, 20];

// Accessing elements
console.log(point[0]); // Output: 10
console.log(point[1]); // Output: 20

// TypeScript will catch invalid access
// console.log(point[2]); // Error: Tuple type '[number, number]' of length '2' has no element at index '2'.

// Modifying elements
point[0] = 30;
console.log(point); // Output: [30, 20]

// TypeScript will catch type mismatches
// point[0] = "thirty"; // Error: Type 'string' is not assignable to type 'number'.

You can also use destructuring assignment to extract tuple values into separate variables, making your code more readable:

let person: [string, number, boolean] = ["John", 30, true];
let [name, age, isAdmin] = person;

console.log(name); // Output: "John"
console.log(age); // Output: 30
console.log(isAdmin); // Output: true

You can also lock a tuple down completely with the readonly modifier:

let point: readonly [number, number] = [10, 20];
// point[0] = 30; // Error: Cannot assign to '0' because it is a read-only property.

If you're doing data transformations that need to stay type-safe through the whole pipeline, Convex's complex filters guide shows how this plays out in practice.

Enforcing Fixed-Size Arrays using Tuples in TypeScript

Tuples in TypeScript strictly enforce their defined length, making them perfect for cases where the number of elements must remain constant. This constraint helps prevent bugs that might occur with regular TypeScript arrays when adding or removing elements unexpectedly:

let point: [number, number] = [10, 20];

// Adding elements is not allowed
// point.push(30); // Error: Property 'push' does not exist on type '[number, number]'.

// This assignment fails because the sizes don't match
// point = [10, 20, 30]; // Error: Type '[number, number, number]' is not assignable to type '[number, number]'.

The compiler guarantees the shape stays consistent everywhere -- you can't accidentally assign a three-element tuple where a two-element one is expected. That's especially useful for coordinate pairs, RGB values, or any structure where length is part of the contract.

For example, defining a color as a tuple with exactly three components:

type RGB = [number, number, number];

function createColor(r: number, g: number, b: number): RGB {
return [r, g, b];
}

const red: RGB = createColor(255, 0, 0);
const green: RGB = createColor(0, 255, 0);
// const invalid: RGB = [0, 255]; // Error: Type '[number, number]' is not assignable to type 'RGB'.

If an external API or protocol expects data in an exact format, a tuple makes that expectation explicit in the type rather than in a comment.

Variadic Tuple Types in TypeScript

Variadic tuple types, introduced in TypeScript 4.0, let you express tuples with a fixed prefix followed by a variable-length tail. This goes beyond basic rest elements and opens up some powerful patterns for typed function signatures.

The core syntax is a rest element with a generic array type:

type StringsAfterNumber = [number, ...string[]];

const log: StringsAfterNumber = [200, "OK", "Request completed"];
const minimal: StringsAfterNumber = [404]; // rest elements can be empty

Where variadic tuples really shine is in higher-order functions and middleware patterns, where you need to prepend or append fixed arguments to a variable argument list:

// A typed event handler that always starts with an event ID
type EventHandler<T extends unknown[]> = [eventId: string, ...args: T];

type ClickHandler = EventHandler<[x: number, y: number]>;
// Resolves to: [eventId: string, x: number, y: number]

type KeyHandler = EventHandler<[key: string, modifiers: string[]]>;
// Resolves to: [eventId: string, key: string, modifiers: string[]]

You can also use variadic types to concatenate tuples, which is useful for building typed pipelines:

type Concat<T extends unknown[], U extends unknown[]> = [...T, ...U];

type AB = Concat<[string, number], [boolean, Date]>;
// Result: [string, number, boolean, Date]

Implementing Optional and Rest Elements in TypeScript Tuples

Not every tuple needs to be completely rigid. Optional and rest elements let you express variable-length tuples without giving up type safety.

Optional elements get a ? after the label (or type, if unlabeled):

let point: [number, number, string?] = [10, 20];
console.log(point.length); // Output: 2

// Adding the optional element is allowed
point = [10, 20, "origin"];
console.log(point.length); // Output: 3

// Adding extra elements beyond what's defined is not allowed
// point = [10, 20, "origin", "extra"]; // Error: Type '[number, number, string, string]' is not assignable to type '[number, number, string?]'.

Rest elements use the spread syntax (...) to allow an arbitrary number of elements of a specified type at the end of the tuple:

let logEntry: [number, ...string[]] = [200, "OK", "Request completed in 42ms"];
logEntry = [404, "Not Found", "Resource missing", "Check the URL"];
console.log(logEntry.length); // Output: 4

// The first element must still match the defined type
// logEntry = ["200", "OK"]; // Error: Type 'string' is not assignable to type 'number'.

This comes in handy for APIs that return varying amounts of data, or functions with optional trailing parameters. You can also combine TypeScript utility types like Partial<T> with tuples when you need even more control.

Mixing optional and rest elements gives you the most range:

type FlexibleTuple = [number, string?, ...boolean[]];

const a: FlexibleTuple = [42];
const b: FlexibleTuple = [42, "hello"];
const c: FlexibleTuple = [42, "hello", true, false, true];

Enforcing Specific Data Types at Each Position in a TypeScript Tuple

Each position in a tuple can hold a completely different type, and TypeScript enforces that at every access point:

let person: [string, number, boolean] = ["John", 30, true];
console.log(person[0]); // Output: "John" (string)
console.log(person[1]); // Output: 30 (number)
console.log(person[2]); // Output: true (boolean)

// TypeScript will catch type mismatches at each position
// person = [30, "John", true]; // Error: Type 'number' is not assignable to type 'string'.
// person = ["John", "30", true]; // Error: Type 'string' is not assignable to type 'number'.

You catch swapped values and wrong types before the code ever runs -- not during a debugging session at 11pm.

You can combine this feature with TypeScript type assertion when working with external data sources:

function parseUserData(data: any): [string, number, boolean] {
// Validate and process the data
if (typeof data[0] !== 'string' || typeof data[1] !== 'number' || typeof data[2] !== 'boolean') {
throw new Error('Invalid user data format');
}
return data as [string, number, boolean];
}

const userData = parseUserData(["Alice", 25, false]);

For a deeper look at validating structured data from APIs and user input, Convex's argument validation techniques covers this pattern in detail.

Type aliases help here too -- they let you name the shape once and reuse it:

type UserProfile = [name: string, age: number, isAdmin: boolean];
type Point3D = [x: number, y: number, z: number];

function createUser(name: string, age: number, isAdmin: boolean): UserProfile {
return [name, age, isAdmin];
}

Converting Arrays to Tuples and Vice Versa in TypeScript

Converting between arrays and tuples is possible, but type safety isn't automatic -- you have to be deliberate about it:

// Converting from array to tuple using type assertion
let array: number[] = [1, 2, 3];
let tuple: [number, number, number] = array as [number, number, number];

// This works because the array has the right length and element types
console.log(tuple); // Output: [1, 2, 3]

// Converting from tuple to array using spread operator
let tuple2: [number, number, number] = [1, 2, 3];
let array2: number[] = [...tuple2];
console.log(array2); // Output: [1, 2, 3]

The as assertion bypasses the compiler's length check, so it's on you to verify the array is the right size. TypeScript arrays don't carry length information in their type -- tuples do. Adding a runtime check closes that gap:

function toTuple<T extends any[]>(arr: any[], tupleType: T): T {
if (arr.length !== tupleType.length) {
throw new Error(`Expected array of length ${tupleType.length}, got ${arr.length}`);
}
return arr as T;
}

// Usage
const data = [10, 20];
const point = toTuple(data, [0, 0] as [number, number]);

This pattern is handy any time you're pulling data from an external source and need the compiler to trust you on structure. With TypeScript generics, you can make the validator reusable:

function toTypedTuple<T extends any[]>(
arr: unknown[],
validator: (arr: unknown[]) => boolean
): T | null {
return validator(arr) ? (arr as T) : null;
}

// Example validator for a [string, number] tuple
const isNameAgeTuple = (arr: unknown[]): boolean =>
arr.length === 2 && typeof arr[0] === 'string' && typeof arr[1] === 'number';

const userData = toTypedTuple<[string, number]>(['Alice', 30], isNameAgeTuple);

Problems You're Likely to Hit

Function Overloading with Tuples

Function overloading lets you vary behavior based on which tuple shape was passed in:

// Overloaded function signatures
function process(data: [string]): string;
function process(data: [string, number]): number;
function process(data: [string, number?]): string | number {
if (data.length === 1) {
return `Processing: ${data[0]}`;
} else {
return data[1] * 2;
}
}

console.log(process(["test"])); // Output: "Processing: test"
console.log(process(["test", 42])); // Output: 84

Type Narrowing with Tuples

With a union of tuple types, TypeScript narrows based on .length -- no extra type guards needed:

type Point2D = [number, number];
type Point3D = [number, number, number];
type Point = Point2D | Point3D;

function calculateDistance(point: Point): number {
// Type guard to narrow down the tuple type
if (point.length === 3) {
// TypeScript knows this is Point3D
return Math.sqrt(point[0]**2 + point[1]**2 + point[2]**2);
} else {
// TypeScript knows this is Point2D
return Math.sqrt(point[0]**2 + point[1]**2);
}
}

Preserving Tuple Types with Higher-Order Functions

Calling .map() on a tuple returns number[], not the original tuple type. You'll need a type assertion to get it back:

const point: [number, number] = [10, 20];
// Without type assertion, this becomes number[]
const doubled = point.map(x => x * 2) as [number, number];

Accidental Array Inference

TypeScript will often infer an array type instead of a tuple when you initialize a variable without an explicit annotation:

// TypeScript infers this as number[], not [number, number]
const point = [10, 20];

// Use an explicit annotation or `as const` to get a tuple
const point1: [number, number] = [10, 20];
const point2 = [10, 20] as const; // readonly [10, 20]

The as const approach is handy when you want a readonly tuple inferred from the literal values. Just note that the type becomes the literal values (10 and 20), not the general types (number and number).

TypeScript Tuples: Key Takeaways

Tuples are the right tool when position carries meaning and the structure is fixed. A few things to keep in mind as you start using them more:

  • Label your tuple elements -- named tuples ([x: number, y: number]) are self-documenting and improve IDE tooling with no runtime cost
  • Prefer tuples over objects for small, ordered, destructured return values -- especially when callers will always destructure the result
  • Use readonly tuples when the data shouldn't change after creation
  • Add an explicit type annotation when initializing a tuple from a literal, or TypeScript will infer a plain array
  • Reach for variadic tuples when you need typed function argument prefixes or to compose tuple types programmatically
  • If you find yourself adding many optional elements, consider whether an interface or object type would be clearer

Tuples let you express structured, ordered data with the same precision as named properties -- just more concisely. Once you start using named tuple elements and variadic patterns, you'll find they cover a lot of ground that previously needed more verbose type annotations.