skip to content

A generic custom hook ends with `return [value, setValue];` and has no return type annotation. Why does the caller's destructured setValue come out with a union type, and how do you type the hook so the pair destructures correctly?

level: middleimportance: should knowfreq 42%

answer

  1. arrays are homogeneous, pairs are not
  2. inference widens to a union of the entries
  3. positions have to be stated somewhere
  4. annotate the signature or freeze the literal
  5. readonly is the cost of the shortcut

basics

~20 s

An array literal is inferred as an array of the union of its element types, so both destructured names get value-or-setter. Annotate the return type as a tuple, such as [T, (next: T) => void], or return the literal with as const.

solid answer

~50 s

Without a contextual type, TypeScript infers an array literal as `Element[]` where `Element` is the union of what is inside — here `(T | ((next: T) => void))[]`. Array element access does not track positions, so destructuring hands both `value` and `setValue` that union, and calling `setValue` is an error until the caller narrows. The fix is to state the shape: annotate the hook's return type explicitly, `function useThing<T>(initial: T): [T, (next: T) => void]`, which is the form most library hooks use and keeps the tuple mutable. `return [value, setValue] as const` also produces a positional type, but a `readonly` one, which is usually fine for a destructured pair but rejects callers who want to spread it into a mutable slot. React's own `useState` is declared with a tuple return, which is precisely why destructuring it works.

code

typescript · 18 lines
typescript
type Setter<T> = (next: T) => void;

// Inferred as (T | Setter<T>)[] — an array, not a pair
function looseHook<T>(initial: T) {
  let value = initial;
  const setValue: Setter<T> = (next) => { value = next; };
  return [value, setValue];
}

// Explicit tuple return type keeps the positions distinct
function pairHook<T>(initial: T): [T, Setter<T>] {
  let value = initial;
  const setValue: Setter<T> = (next) => { value = next; };
  return [value, setValue];
}

const [n, setN] = pairHook(0);   // n: number, setN: (next: number) => void
setN(n + 1);

go deeper

for a junior

Know that a returned array literal is inferred as an array, not a pair, and that a hook returning two different things needs a tuple return type for destructuring to work.

for a middle

Explain the widening rule that produces the union, show both fixes, and say why the explicit return-type annotation on the signature is usually preferred over as const.

for a senior

Weigh the API shape: tuple for a two-member pair whose order is obvious, object once members multiply, and be able to say what an inserted member does to every existing call site.

for a principal

Treat the hook signature as a published contract — labelled tuple members, defaulted type parameters, and the migration cost of switching a shipped tuple to an object are the decisions worth owning.

## Why an array literal is not a pair TypeScript has two related but distinct types for bracketed values. `T[]` is a homogeneous array: any index yields the same element type, and the length is unknown. `[A, B]` is a **tuple**: position 0 has type `A`, position 1 has type `B`, and the length is fixed. When the checker infers a type for an array literal with no contextual type to guide it, it picks the array form and widens the element type to the union of the entries. So this hook: ```typescript type Setter<T> = (next: T) => void; function looseHook<T>(initial: T) { let value = initial; const setValue: Setter<T> = (next) => { value = next; }; return [value, setValue]; // inferred (T | Setter<T>)[] } ``` returns `(T | Setter<T>)[]`. Destructuring reads elements, and every element of that array has the union type, so `const [v, setV] = looseHook(0)` gives both names `number | Setter<number>`. Calling `setV(1)` fails — the checker cannot rule out that it is a `number`. Nothing has gone wrong with generics here; the inference rule is the same for a non-generic function, but generics make it more visible because the union now mentions the type parameter and the error text becomes long. ## The two fixes, and why they differ **Explicit tuple return type** — the one to reach for: ```typescript function pairHook<T>(initial: T): [T, Setter<T>] { let value = initial; const setValue: Setter<T> = (next) => { value = next; }; return [value, setValue]; } const [n, setN] = pairHook(0); // n: number, setN: (next: number) => void ``` The annotation becomes a *contextual type* for the returned literal, so the checker matches it position by position instead of inferring an array. It also documents the hook's contract at the signature, which is what a reader looks at, and it keeps the result mutable. **`as const` on the returned literal**: ```typescript function constHook<T>(initial: T) { let value = initial; const setValue: Setter<T> = (next) => { value = next; }; return [value, setValue] as const; // readonly [T, Setter<T>] } ``` This also produces a positional type, but a `readonly` tuple. For a destructured pair that is harmless and arguably better — nobody should be reassigning the returned array's slots. It only bites when a caller wants to pass the result somewhere that expects a mutable tuple, since `readonly [A, B]` is not assignable to `[A, B]`. Note also that the assertion applies to the whole literal, so it is easy to forget on one of several return statements; the signature annotation cannot be forgotten. ## Naming the positions Tuple members may be labelled, which improves editor hints and error messages without changing the type: ```typescript function useToggle(initial: boolean): [on: boolean, toggle: () => void] { let on = initial; return [on, () => { on = !on; }]; } ``` Labels are documentation only — assignability is unchanged — but for a hook whose two returns are otherwise indistinguishable to a reader, they earn their keep. ## Tuple or object? The tuple exists so that callers can rename freely: two `useState`-style hooks in one function need distinct names, and positional destructuring gives that for nothing. That advantage evaporates past two or three members — a five-element tuple is a memory test, and adding a member in the middle silently reassigns everyone's names. Returning an object is then the better contract: names are explicit, order is irrelevant, and additive changes are backwards-compatible. A good rule is tuple for a pair whose members are obviously ordered (state and setter, value and reset), object for anything richer. Interviewers often push here after the tuple answer, and the expected reasoning is exactly this tradeoff between rename-freedom and self-description. ## The generic angle One detail specific to a generic hook: the type parameter must be inferable from the parameters, because the return type cannot drive inference. `function useThing<T>(initial: T): [T, Setter<T>]` infers `T` from `initial`, which is why `useThing(0)` yields a `number` pair. If the hook takes no argument, the caller has to supply the argument explicitly — `useThing<User | null>()` — or you give the parameter a default. A hook whose parameter is `T | undefined` with a default of `undefined` infers `unknown` when called bare, which is the usual reason a supposedly typed hook comes back useless.

  • When is `as const` the wrong fix for a hook's return?
    When callers need a mutable tuple. `as const` produces `readonly [T, Setter<T>]`, and a `readonly` tuple is not assignable to `[T, Setter<T>]`, so anything expecting a mutable pair rejects it. It is also per-`return`-statement, so a hook with several exits can lose the shape on one path. An explicit return-type annotation on the signature avoids both problems and documents the contract.
  • Why return a tuple rather than an object at all?
    Because positional destructuring lets each call site name the members freely — two state hooks in one component need two distinct names, and a tuple gives that for free. The tradeoff is self-description: past two or three members, order becomes a memory test and inserting a member silently renames everything downstream. Prefer an object once the shape grows or is likely to gain members.
  • The hook takes no argument and T comes back as unknown. What is happening?
    Type parameters are inferred from arguments only; a return type never drives inference. With nothing to infer from, `T` falls back to its constraint or `unknown`. Either give the caller a way to supply it — an initial-value parameter — or expect an explicit type argument at the call, `useThing<User | null>()`. A default type parameter can also pick a sensible fallback instead of `unknown`.

saying these in an interview costs you the question

  • Thinks destructuring alone tells the checker the positions
  • Says the union appears because the function is generic
  • Reaches for any on the setter to silence the error
  • Believes as const and an explicit tuple annotation are identical
  • Claims a tuple has a different runtime representation than an array

context