skip to content

Users of your helper `declare function pipe<A, B>(f: (a: A) => B): (a: A) => B` report that in `pipe(x => x)` the parameter `x` is `unknown`. Why does that happen, and what are your options for fixing it?

level: seniorimportance: should knowfreq 40%

answer

  1. ask where A could possibly come from
  2. the callback consumes A, nothing produces it
  3. context-sensitive arguments are deferred, so order is fine
  4. empty candidate set means the fallback ladder
  5. give the type parameter a value argument

basics

~20 s

A callback parameter consumes A instead of supplying it, so nothing at the call gives A a candidate and it falls back to unknown. Annotate the parameter, add a constraint, or take the value as an argument.

solid answer

~50 s

`A` appears in exactly one place at the call: the parameter of the callback the caller is writing. That position is a *consumer* of `A` — it is where the compiler will push a type in — not a source of candidates. Since nothing else at the call supplies one, the candidate set is empty, `A` falls back to `unknown`, and contextual typing then dutifully types `x` as `unknown`. Note that argument order is a red herring: the compiler defers context-sensitive arguments, so `f(cb, value)` still infers from `value` perfectly well. The fixes, best first: give `A` a real inference site by taking the value as a parameter, `apply<A, B>(value: A, f: (a: A) => B)`; constrain `A` so the fallback is a useful type; let callers annotate, `pipe((x: number) => x)`; or supply the type arguments, which is all-or-nothing — `pipe<number>(...)` is rejected with "Expected 2 type arguments, but got 1" unless `B` has a default.

code

typescript · 11 lines
typescript
declare function pipe<A, B>(f: (a: A) => B): (a: A) => B;

export const p1 = pipe(x => x);                 // (a: unknown) => unknown
export const p2 = pipe((x: number) => x);       // (a: number) => number
export const p3 = pipe<number, string>(String); // (a: number) => string

declare function pipeC<A extends { id: string }, B>(f: (a: A) => B): (a: A) => B;
export const p4 = pipeC(x => x.id);             // (a: { id: string }) => string

declare function apply<A, B>(value: A, f: (a: A) => B): B;
export const p5 = apply({ id: "1" }, x => x.id); // string — A has a real inference site

go deeper

for a junior

Recognise the symptom — a callback parameter arriving as unknown — and know the immediate unblock: annotate the parameter yourself, as in pipe((x: number) => x).

for a middle

Explain that a callback parameter position consumes the type parameter rather than supplying a candidate, and that the empty candidate set is what triggers the unknown fallback.

for a senior

Diagnose it in a library you own: distinguish this from an implicit-any error, dismiss argument order as the cause, and choose between adding a producer parameter and adding a constraint rather than taxing every caller.

for a principal

Treat inferability as a published contract. Set the expectation that a natural call to any exported generic types correctly with zero annotations, and review new signatures for type parameters that have no producer position before they ship.

## Producers and consumers of a type parameter A type parameter can appear in a signature in two different roles, and inference only cares about one of them. - A **producer** position is one where an argument's type flows *into* the type parameter: `f(value: A)`, `f(items: A[])`, `f(cb: () => A)`. The caller writes a value, the compiler reads a candidate. - A **consumer** position is one where the type parameter flows *out* to describe something: the return type, or the parameters of a callback the caller supplies. `pipe<A, B>(f: (a: A) => B)` puts `A` in a consumer position only. The `a: A` slot exists so the compiler can tell callers what their callback receives; it is not a place where the caller announces a type. `B`, by contrast, has a producer position — the callback's return — which is why `pipe((x: number) => x)` infers `B` correctly once `A` is known. With an empty candidate set, the fallback ladder applies: a declared default, else the constraint, else `unknown`. `A` has neither, so `A` becomes `unknown`, and contextual typing then pushes `unknown` onto the callback's parameter. That is why the reporter sees `x: unknown` and not an implicit-`any` error — nothing is missing an annotation; inference genuinely ran and landed on the fallback. ```ts declare function pipe<A, B>(f: (a: A) => B): (a: A) => B; const p = pipe(x => x); // ^? (a: unknown) => unknown ``` ## Argument order is not the bug The reflex diagnosis — "the callback comes first, so inference has not seen the value yet" — is wrong, and saying so is a good way to show you have actually debugged this. A function expression with un-annotated parameters is **context-sensitive**: its type depends on what is pushed into it. The compiler processes non-context-sensitive arguments first, fixes what it can, and revisits the context-sensitive ones in a later round. So both of these infer correctly: ```ts declare function withInit<T>(cb: (t: T) => void, init: T): void; withInit(t => { t.a; }, { a: 1 }); // fine — T comes from init ``` The problem in `pipe` is not ordering, it is that no argument anywhere produces `A`. ## Four fixes, in the order to consider them **1. Give the type parameter a real inference site.** If the API can take the value, take it — this is the fix that removes the burden from every caller: ```ts declare function apply<A, B>(value: A, f: (a: A) => B): B; apply({ id: "1" }, x => x.id); // string, x fully typed ``` **2. Constrain the type parameter so the fallback is useful.** When the API genuinely cannot see a value, a constraint turns `unknown` into something callers can work with: ```ts declare function pipeC<A extends { id: string }, B>(f: (a: A) => B): (a: A) => B; pipeC(x => x.id); // (a: { id: string }) => string ``` The callback parameter is now contextually typed by the constraint, so `x.id` checks. This is the single highest-leverage change for builder-style APIs whose type parameter cannot be inferred from arguments. **3. Let the caller annotate the callback parameter.** `pipe((x: number) => x)` works, because an annotated parameter is no longer waiting for a context — it supplies the candidate itself, and `A` infers as `number`. Cheap, but it is a tax paid at every call site. **4. Explicit type arguments — with a caveat.** `pipe<number, string>(String)` is legal, but partial application is not: `pipe<number>(x => x)` is rejected with "Expected 2 type arguments, but got 1". You must name every type argument unless the trailing ones declare defaults, which is a good reason to put the parameter callers will want to specify first and give the rest defaults. One more that surprises people: the call's contextual type can also supply the candidate, because an annotated receiving variable is an inference source of lower priority than arguments. ```ts const p: (a: number) => number = pipe(x => x); // A is number ``` That works, but it is a call-site workaround rather than an API fix. ## The judgment an interviewer is listening for All four options compile. Only the first two change the *library*, and that is the distinction to make explicit: option 3 and option 4 push the cost onto every consumer forever, and the cost is paid in annotations that will drift. When you own the signature, the question is not "how do I make this call compile" but "why does this type parameter have no producer, and can the API be reshaped so a natural call needs zero annotations?" Inferability is an API quality attribute — a helper whose callbacks only type correctly when consumers annotate them seeds `unknown` and `any` across every downstream codebase, and none of it is visible at runtime, since the whole type layer is erased.

  • Would moving the callback to the last parameter fix inference in general?
    Not by itself. Function expressions with un-annotated parameters are context-sensitive, and the compiler defers them to a later inference round, so `f(cb, value)` infers from `value` just as well as `f(value, cb)`. Order affects readability and partial-application ergonomics, not whether a candidate exists. Reordering only helps if it accompanies actually adding a producer position for the type parameter.
  • Why does the caller see `unknown` rather than an implicit-`any` error here?
    Because nothing is un-annotated from the checker's point of view. Inference ran, found no candidate for `A`, applied its fallback, and contextual typing then pushed that fallback onto the callback parameter — so the parameter has a type. `noImplicitAny` fires only when no context exists at all, as with a standalone `const cb = (x) => ...`. Same symptom on screen, different diagnosis.
  • Why can't the caller write `pipe<number>(x => x)` and let `B` be inferred?
    Type arguments are all-or-nothing: supply one and you must supply them all, unless the trailing parameters declare defaults. `pipe<number>(...)` is rejected with "Expected 2 type arguments, but got 1". If you expect callers to pin only the first parameter, declare a default for the rest — or better, reshape the signature so the common case needs no type arguments at all.

saying these in an interview costs you the question

  • Blames argument order for the failed inference
  • Says the parameter is an implicit any error
  • Claims adding strict flags would infer the type
  • Thinks a callback parameter position supplies a candidate
  • Fixes it only at call sites and never touches the signature

context