Tuple & Variadic Generics

Advanced TypeScript — Tuple & Variadic Generics
Advanced TypeScript
Course 4 · Chapter 3 · Tuple & Variadic Generics

🧬 Tuple & Variadic Generics

Chapter 2's counting tuple used [...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.

→ Solution

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.

→ Solution

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.

→ Solution

⚠️ Gotcha: Arrays Widen, Tuples Don't (Without Help)

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.