23.6 Variadic Tuple Types: Spreading Tuples in Type Position
Alright, let’s get our hands dirty with variadic tuple types. If you’ve ever found yourself wanting to write a function that takes a flexible number of arguments and returns a tuple with a related but transformed structure, only to be met with the cold, hard wall of any[], this is your ticket out. This feature is essentially the spread operator (...) for type definitions, and it’s the secret sauce that makes modern TypeScript’s builder patterns and functional utilities so powerfully type-safe.
The core idea is simple: you can use ...T within a tuple type definition, where T is another tuple type (or a generic that extends a tuple). This lets you represent an arbitrary number of elements in a type position, preserving their order and individual types. It turns TypeScript from a static type checker into a kind of type-level programming language.
The Basic Mechanics: Unpacking the Syntax
Let’s start with the simplest possible example. Imagine you have two tuples and you want to concatenate them into a new type. Before variadic tuples, this was a fantasy. Now, it’s trivial.
type Start = [string, number];
type End = [boolean, ...string[]];
// The result is [string, number, boolean, ...string[]]
type Concatenated = [...Start, ...End];
// A function that uses this
function joinTuples(start: Start, end: End): Concatenated {
return [...start, ...end];
}
const result = joinTuples(['hello', 42], [true, 'a', 'b', 'c']);
// result is inferred as [string, number, boolean, string, string, string]
console.log(result); // ['hello', 42, true, 'a', 'b', 'c']
See what happened there? The type system understood the exact structure of the output based on the inputs. We didn’t lose the specific number type of 42 to a generic unknown or any; it’s right there in the resulting type. This is a quantum leap from the old ways of using Array<T>.
Why This is a Game Changer for Function Signatures
This is where the real power lies. You can now create functions that are variadic in a type-safe way, beyond the simple ...args: string[] approach. Let’s build a function that prefixes a value to a tuple.
function prefixTuple<T, U extends any[]>(value: T, tuple: U): [T, ...U] {
return [value, ...tuple];
}
// Usage is beautifully typed:
const prefixed = prefixTuple('new', ['old', 42]);
// prefixed is inferred as [string, string, number]
The return type [T, ...U] is the magic. It says “Take the type T of our first argument, and then spread out the types of the tuple U that followed.” The type U is captured as a tuple, not just as an array of its element types. This preserves the length and order.
The infer Keyword and Recursive Type Magic
To truly flex, you need to combine this with the infer keyword and recursive types. This is how you build those “I can’t believe TypeScript can do that” types. Let’s write a type that strips the last element off a tuple.
type StripLast<T extends any[]> = T extends [...infer Rest, infer _Last] ? Rest : never;
type Test = StripLast<[string, number, boolean]>;
// Test is [string, number]
// And a function to match:
function popTuple<T extends any[]>(tuple: [...T]): StripLast<T> {
return tuple.slice(0, -1) as StripLast<T>;
}
const popped = popTuple(['a', 1, true]);
// popped is inferred as [string, number]
Let’s break down StripLast<T>. It’s a conditional type that says: “If T can be split into a new tuple Rest and a final element _Last (whose type we don’t care about, hence the underscore), then just give me the Rest part.” This is type-level pattern matching, and it’s incredibly powerful.
The Rough Edges and Pitfalls
Now, let’s be the brilliant friend who tells you where the landmines are. This power comes with quirks.
The
asAssertion Escape Hatch: Notice I usedas StripLast<T>in thepopTuplefunction. This is becauseArray.prototype.slicereturns a vanilla array type (any[]), not a tuple. The type system is smart, but the runtime isn’t. You often need to help TypeScript bridge the gap between the runtime result of array methods and the sophisticated type you’ve defined. Use these assertions judiciously and only when you’re absolutely certain of the logic.Empty Tuples Can Bite You: What happens if you call our
popTuplefunction with a single-element tuple or, heaven forbid, an empty one?const badPop = popTuple(['a']); // returns [], type is never[] const worsePop = popTuple([]); // COMPILE ERROR, but would also return []Our
StripLast<['a']>becomesStripLast<[string]>. This matches[...infer Rest, infer _Last]whereRestis[]and_Lastisstring. So it returns[], which is correct. But anever[]is weird. For an empty tuple,Tis[], which does NOT match the pattern[...infer Rest, infer _Last](because there’s no_Lastto infer), so it returnsnever. You must consider these edge cases in your types. A more robust version might use constraints to prevent empty tuples.Performance on Deeply Recursive Types: If you go down the rabbit hole of creating deeply recursive types (e.g., a type that recursively maps over a 100-element tuple), you might hit TypeScript’s internal recursion limits or see slower editor performance. It’s a good problem to have—it means you’re doing something truly complex—but be aware of it.
The designers gave us an incredibly powerful tool, but they rightly left it to us to use it responsibly. It’s the difference between giving someone a scalpel and giving them a butter knife. The scalpel is far more capable, but you can also do more damage if you’re careless. Use it to build brilliant, type-safe APIs, but always test your edge cases.