Lesson 17 · Module 2.5 — Generics

Rest & tuple params: <TArgs extends any[]> captures a whole signature

How does a wrapper like safeFunction(fn) preserve any function’s parameter list? The trick comes down to one constrained generic: <TArgs extends any[]>(...args: TArgs). That any[] bound makes TArgs stand for the whole parameter tuple — and that’s the difference between a wrapper that works and one that fails.

The one idea

<TArgs extends any[]> says “TArgs is a tuple type.” Combined with ...args: TArgs, the entire argument list — [], [string], [string, number] — becomes one captured thing TS can infer and re-spread.

The working version: TArgs extends any[]

type PromiseFunc<TArgs extends any[], TResult> =
  (...args: TArgs) => Promise<TResult>;

const safeFunction =
  <TArgs extends any[], TResult>(func: PromiseFunc<TArgs, TResult>) =>
  async (...args: TArgs) => { /* ...call func(...args) safely... */ };

Because TArgs is bound to any[], TS treats it as a tuple and infers it straight from whatever function you hand in. A table of cases:

TArgs = []               // for () => Promise<T>
TArgs = [string]         // for (s: string) => Promise<T>
TArgs = [string, number] // for (s: string, n: number) => Promise<T>

So ...args: TArgs on the returned wrapper re-emits the original signature exactly. Pass async () => 123 and TS infers TArgs = [], giving a wrapper of type () => Promise<number> — a perfect match.

Why a tuple, not an array?

First, why is a tuple even allowed? Because a constraint is a ceiling, not the answer: extends any[] says “TArgs may be any array-like type,” and a tuple is array-like ([string, number] is a subtype of any[]). So the bound permits a tuple — it does not force the wide any[].

But “allowed” is not “chosen.” What actually produces the tuple is the rest position. ...args: TArgs captures the function’s whole parameter list, and TypeScript models a parameter list as a tuple — fixed length, a type per slot, even the labels. Matching (s: string, n: number) against (...args: TArgs) reads off TArgs = [s: string, n: number] exactly. (This rest-into-tuple inference was added in TS 3.0 for exactly this “capture and replay a signature” case.)

Not the same as Lesson 2.2’s widening

An array-typed constraint does not narrow on its own. In Lesson 2.2, <T extends string[]>(statuses: T) fed a plain array literal widened to string[] — no tuple, even though string[] is just as much a “container” bound as any[]. So the bound is not the deciding factor; the rest position capturing a parameter list is — versus a direct parameter capturing an array value, which widens. Same container constraint, opposite result. (The other way to turn a plain array value into a tuple is to pin it with as const — also Lesson 2.2.)

The broken version: TArgs unconstrained

Drop the extends any[] and change the spread to ...args: TArgs[], and the meaning of TArgs silently shifts from “the tuple” to “one element of the tuple”:

type PromiseFunc<TArgs, TResult> =
  (...args: TArgs[]) => Promise<TResult>;  // TArgs is now an ELEMENT type

const safeFunction =
  <TArgs, TResult>(func: PromiseFunc<TArgs, TResult>) =>
  async (...args: TArgs[]) => { /* ... */ };

const func = safeFunction(async () => 123);
// ❌ () doesn't match (...args: TArgs[]); TS can't figure out what TArgs should be.

The diagnosis: with ...args: TArgs[], TArgs is the element type, so TS must reverse-engineer “what element makes an array that matches ()?” — and there’s no answer for a zero-argument function. Inference fails. You’d have to specify it by hand, safeFunction<never, number>(...), which defeats the point.

versionwhat TArgs meansinfers from () => …?
<TArgs extends any[]> ...args: TArgsthe whole tupleyes — TArgs = []
<TArgs> ...args: TArgs[]one elementno — unresolvable
Reading trap

...args: TArgs and ...args: TArgs[] look almost identical but are opposite ideas. TArgs (with the any[] bound) is the list; TArgs[] is an array of one element type. The bound is the tell — read it first.

Why this pattern matters

This is the engine behind every “wrap a function but keep its signature” helper — debouncers, loggers, error-catchers. The whole capability rests on that one bound, extends any[], turning TArgs into a tuple TS can both infer and spread back out.

Check yourself

Recall, don’t re-read. (Answers reveal on click.)

With <TArgs extends any[]>(...args: TArgs), what does TArgs capture?
Why does the unconstrained ...args: TArgs[] version fail on async () => 123?
For an input function (s: string) => Promise<T>, the working version infers TArgs as…

Primary source

The official page is TypeScript Handbook — Generics; the tuple/rest mechanics are in Tuple Types and the release note on tuples in rest parameters. Best way to feel it: paste both safeFunction versions into the TS Playground and hover the inferred TArgs.

Try it yourself

Got a function wrapper that loses its signature? Paste it into the Playground and ask “is TArgs the tuple or an element?” That single question, and the extends any[] bound, is usually the whole fix.