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.