Tuple & Variadic Generics
๐งฌ Tuple & Variadic Generics
[...Acc, T] almost in passing. This chapter makes tuple spreading the main event: prepending and appending types, variadic generics that adapt to however many arguments a function actually receives, and the two functions every advanced-TypeScript tour eventually builds โ a type-safe curry and a type-safe pipe.
Spreading Types Into Tuples
The ... spread syntax works inside a tuple type, not just inside array values โ it splices one tuple's elements into another at the type level:
type Prepend<T, Tup extends unknown[]> = [T, ...Tup]; type Append<Tup extends unknown[], T> = [...Tup, T]; type A = Prepend<string, [number, boolean]>; // [string, number, boolean] type B = Append<[number, boolean], string>; // [number, boolean, string]
This is exactly the mechanism behind Chapter 2's Repeat<T, N, Acc> โ every recursive call was really just Append in disguise.
๐๏ธ Variadic Tuple Types
A generic parameter constrained to unknown[] can represent any number of elements โ variadic generics use this to describe functions whose argument count isn't fixed in advance:
function concat<T extends unknown[], U extends unknown[]>(a: T, b: U): [...T, ...U] { return [...a, ...b] as [...T, ...U]; } const result = concat([1, "a"] as [number, string], [true]); // result: [number, string, boolean] โ the exact concatenated tuple type, // not just (number | string | boolean)[]
Spread in the Return Type
[...T, ...U] in a function's return position joins two generic tuples exactly the way spreading joins them in a value.
Named Tuple Elements
[first: string, second: number] attaches labels for readability in tooltips โ purely cosmetic, but makes long tuple types self-documenting.
Building a Type-Safe curry
Curry needs to peel one parameter off a function's parameter tuple at a time and recurse on what's left โ the same tuple-recursion pattern from Chapter 2, applied to parameter lists instead of tree depth:
A Recursive, Fully-Typed Curry
type Curried<Args extends unknown[], Return> = Args extends [infer First, ...infer Rest] ? (arg: First) => Curried<Rest, Return> : Return; function curry<Args extends unknown[], Return>( fn: (...args: Args) => Return ): Curried<Args, Return> { return ((...args: any[]) => args.length >= fn.length ? fn(...(args as Args)) : curry(fn.bind(null, ...args) as any) ) as Curried<Args, Return>; } function add3(a: number, b: number, c: number): number { return a + b + c; } const curriedAdd3 = curry(add3); curriedAdd3(1)(2)(3); // โ 6 โ and each intermediate call is fully typed as (arg: number) => ...
Curried<Args, Return> recurses on the parameter tuple exactly like FlattenArray recursed on nested array types in Chapter 2 โ one element consumed per recursive step, base case when the tuple is empty.
๐ Type-Safe pipe
pipe needs the opposite shape: instead of one function's parameters, it chains many functions where each one's output must match the next one's input:
function pipe<A, B>(fn1: (a: A) => B): (a: A) => B; function pipe<A, B, C>(fn1: (a: A) => B, fn2: (b: B) => C): (a: A) => C; function pipe<A, B, C, D>(fn1: (a: A) => B, fn2: (b: B) => C, fn3: (c: C) => D): (a: A) => D; function pipe(...fns: Function[]) { return (input: unknown) => fns.reduce((acc, fn) => fn(acc), input); } const process = pipe( (n: number) => n * 2, (n: number) => n.toString(), (s: string) => `Result: ${s}` ); process(5); // "Result: 10" โ typed as string all the way through
Overloads Without Writing Overloads
The pipe example above needed a separate overload signature for every chain length โ exactly the pain variadic tuples exist to remove:
Manual Overloads vs One Variadic Signature
Manual Overloads
function pipe<A, B>(f1: (a: A) => B): (a: A) => B; function pipe<A, B, C>(f1: (a: A) => B, f2: (b: B) => C): (a: A) => C; // ...one more overload per additional function in the chain, // forever. Chain of 6 functions? 6 overload signatures.
One Variadic Signature
type Func = (arg: any) => any; function pipe<Fns extends [Func, ...Func[]]>( ...fns: Fns ): (arg: Parameters<Fns[0]>[0]> => unknown { return (input) => fns.reduce((acc, fn) => fn(acc), input) as unknown; } // One signature, any length โ the real trade-off is losing full // per-step return-type inference without more advanced recursive types.
This is the same trade-off libraries like Promise.all and Function.prototype.bind's type definitions navigate internally โ a handful of hand-written overloads for the common, small cases, falling back to a looser variadic signature for everything else.
๐ป Coding Challenges
Challenge 1: Write Head and Tail
Write two tuple utility types: Head<T> (the first element's type) and Tail<T> (a tuple of every element except the first), each using tuple destructuring in the type position.
Goal: Practice the [infer First, ...infer Rest] pattern used inside Curried in this chapter.
Challenge 2: Write a Type-Safe zip
Write a zip<T extends unknown[], U extends unknown[]>(a: T, b: U) function whose return type pairs up each element by position โ e.g. zip([1, 2], ["a", "b"]) should type as [number, string][].
Goal: Practice combining two generic tuples element-by-element rather than just concatenating them.
Challenge 3: Extend Curried for Multi-Arg Currying
The curry example only ever peels off one argument at a time. Extend the type so calling with multiple arguments at once (e.g. curriedAdd3(1, 2)(3)) is also valid and correctly typed.
Goal: Practice recursing on a tuple by removing a variable-length prefix rather than always exactly one element.
const arr = [1, "a"] infers as (string | number)[] โ a flexible array, not the tuple [number, string] you probably wanted for something like the concat example. Add as const ([1, "a"] as const) to force a literal, fixed-length tuple type, or annotate the parameter explicitly with a tuple type as the chapter's examples do. Forgetting this is the single most common reason a "type-safe" tuple function silently falls back to loose array typing.
๐ฏ What's Next
With tuples fully under control, the next chapter turns to a different kind of reusability: Type Predicates & Type Guards at Scale โ building narrowing utilities that stay useful across an entire codebase instead of being written fresh for every discriminated union.