skip to content

In TypeScript, `declare function f<T extends readonly unknown[]>(arr: T): T` called as `f([1, "a"])` infers `(string | number)[]` rather than the tuple `[1, "a"]`. What signature changes make it infer a tuple, and what does each produce?

level: seniorimportance: should knowfreq 28%

answer

  1. a tuple only when something asks
  2. rest parameters match positionally
  3. the constraint is a bound, not a request
  4. readonly survives only if the constraint allows
  5. positions versus element literals

basics

~20 s

An array literal only becomes a tuple when something asks for one. Make the parameter a rest parameter and TypeScript infers positionally, or add a const type parameter with a readonly array constraint to infer a readonly tuple of literal element types.

solid answer

~50 s

Inference produces a tuple only when a position asks for one. With `arr: T` and `T extends readonly unknown[]`, the array literal has no contextual tuple type, so its elements are widened and unioned: `f([1, "a"])` infers `(string | number)[]`. Two signature changes fix it. Make the parameter a **rest** parameter — `declare function f<T extends unknown[]>(...args: T): T` — and `f(1, "a")` infers the tuple `[number, string]`, because rest arguments are matched positionally. Or use a **const** type parameter: `declare function f<const T extends readonly unknown[]>(arr: T): T` infers `readonly [1, "a"]`, keeping both positions *and* the literal element types. The constraint must permit readonly arrays for that readonly tuple to survive; with a mutable `unknown[]` constraint the readonly modifier is dropped. The remaining option is to make callers apply a const assertion, which moves the burden to every call site.

code

typescript · 9 lines
typescript
declare function arr<T extends readonly unknown[]>(a: T): T;
declare function rest<T extends unknown[]>(...args: T): T;
declare function cnst<const T extends readonly unknown[]>(a: T): T;
declare function mut<const T extends string[]>(a: T): T;

const a = arr([1, "a"]);      // (string | number)[]
const b = rest(1, "a");       // [number, string]
const c = cnst([1, "a"]);     // readonly [1, "a"]
const d = mut(["x", "y"]);    // ["x", "y"] - readonly dropped

go deeper

for a junior

Know that passing an array literal usually gives an array type, and that getting a tuple type out of a helper requires the signature or the caller to ask for one.

for a middle

Explain that a constraint is a bound rather than a contextual type, and show the rest-parameter form that makes inference positional.

for a senior

Pick the right technique per API — rest for variadic helpers, const type parameter for array arguments whose exact values matter — and know the readonly-versus-constraint interaction.

for a principal

Weigh precision against blast radius: tuple and readonly types propagate into declaration files, hovers and consumer code, and can force copies at boundaries that expect mutable arrays.

## Why the default is an array, not a tuple A tuple type records both length and per-position types; an array type records neither. TypeScript gives an array literal a tuple type only when something *asks* for one — a contextual tuple type, a rest position, or a const assertion. Absent that, the literal's element types are widened and unioned into a single array type. A constraint of `readonly unknown[]` does not count as asking. It is a bound on what `T` may be, not a contextual type that shapes the literal: ```ts declare function f<T extends readonly unknown[]>(arr: T): T; const a = f([1, "a"]); // (string | number)[] ``` Both pieces of information a caller cared about are gone: `a[0]` is `string | number` rather than `1`, and the length is unknown. ## Change one: a rest parameter Rest parameters are matched against arguments **positionally**, which is exactly the shape of a tuple. So a type parameter constrained to an array type in a rest position infers a tuple: ```ts declare function tup<T extends unknown[]>(...args: T): T; const b = tup(1, "a"); // [number, string] ``` Positions survive; literal element types do not, because each argument is still an ordinary widening inference. This is the classic pre-5.0 tuple-inference technique, and it is still the right one when the call is naturally variadic — `zip`, `all`, `pipe`, `combine`. It changes the call shape, though: callers write `tup(1, "a")`, not `tup([1, "a"])`. ## Change two: a const type parameter Since TypeScript 5.0, the `const` modifier makes inference behave as though the argument carried a const assertion, which does ask for a tuple — and keeps the element literals too: ```ts declare function ctup<const T extends readonly unknown[]>(arr: T): T; const c = ctup([1, "a"]); // readonly [1, "a"] ``` This keeps the array-argument call shape and gives the most precise result. It combines with a rest parameter as well: `<const T extends readonly unknown[]>(...args: T)` on `(1, "a")` infers `readonly [1, "a"]`. The constraint has to permit readonly arrays. If it does not, the readonly modifier is dropped rather than the call failing, because a readonly array is not assignable to a mutable array type: ```ts declare function m<const T extends string[]>(x: T): T; const d = m(["a", "b"]); // ["a", "b"] — mutable tuple declare function r<const T extends readonly string[]>(x: T): T; const e = r(["a", "b"]); // readonly ["a", "b"] ``` So write the constraint as `readonly unknown[]` when you want the readonly tuple, and as `unknown[]` when downstream code needs a mutable one. ## Change three: push it to the caller The caller can always apply a const assertion at the call site, which makes the literal non-fresh and tuple-shaped before inference even starts. It keeps the signature minimal and works on any compiler version, but every call site has to remember, and forgetting produces no error — just a less useful type. For a library helper the signature-side fix is usually the better trade, since it is written once. ## Which to choose - Variadic call shape, positions matter, element literals do not: **rest parameter**. - Array-argument call shape, and you need the exact values (route names, event keys, column ids): **const type parameter with a readonly constraint**. - Rare call site, or you must support an older compiler: **caller-side const assertion**. ## The cost side Precise tuple types are not free. They are larger than array types, they show up in hovers, error messages and emitted declaration files, and a deeply readonly tuple can fail to satisfy a downstream API that wants a mutable array — often forcing a spread copy at the boundary. Ask precise inference for the arguments whose exact shape the API's value depends on, and let the rest widen. As always, none of this exists after compilation: the emitted JavaScript for a rest call, an array call, and a const-asserted call is ordinary JavaScript with no type information at all.

  • Why does the rest-parameter version keep positions but lose the literal element types?
    Because those are two separate mechanisms. The rest position supplies the positional matching that makes a tuple, but each argument is still inferred by ordinary rules, and a fresh literal widens to its base primitive. So `tup(1, "a")` gives `[number, string]`. Add the `const` modifier to the same signature and you get `readonly [1, "a"]`, since that additionally suppresses the widening.
  • How do you get a mutable tuple of literal types rather than a readonly one?
    Keep the `const` modifier but write a mutable constraint: `<const T extends string[]>` infers `["a", "b"]`. The readonly modifier is dropped because a readonly array is not assignable to `string[]`, and the compiler drops it rather than rejecting the call. The trade is that the constraint then refuses callers who legitimately pass a readonly array.
  • Does a caller-side const assertion give the same result as a const type parameter?
    Effectively yes for the inferred type — both yield a readonly tuple of literal element types — but the responsibility moves. With the signature-side modifier the guarantee holds for every call; with the caller-side assertion each call site must remember, and forgetting is silent, producing a widened array type and no error. Signature-side is the better default for a shared API.

saying these in an interview costs you the question

  • Assumes a readonly array constraint by itself forces tuple inference
  • Thinks tuple inference needs a specific compiler flag
  • Says readonly always survives regardless of the constraint
  • Believes the rest-parameter trick also preserves literal element types
  • Treats the inferred readonly tuple as runtime immutability

context