For `declare function pair<T>(x: T, y: T): T[]` in TypeScript, how does the compiler resolve `T` when the two arguments suggest different types — compare `pair(1, "x")` with `pair(animal, dog)` where `Dog extends Animal`?
answer
- each argument offers a candidate
- one candidate must cover the others
- supertype wins, and order is irrelevant
- no silent union for mismatched primitives
- explicit type argument settles it
basics
~20 sTypeScript collects one candidate per inference site and keeps the candidate that is a supertype of the others, so pair(animal, dog) gives Animal[]. When no candidate covers the rest, pair(1, "x") is a compile error rather than a silent union.
solid answer
~50 sBoth parameters mention `T`, so each argument contributes a candidate and the compiler has to pick one. Its rule is best common supertype: if one candidate is assignable-from all the others, that one wins. `pair(animal, dog)` yields `Animal[]` — in either argument order — because `Dog` is assignable to `Animal` but not the reverse. With `pair(1, "x")` neither `number` nor `string` covers the other, so inference settles on one candidate and the remaining argument fails the ordinary assignability check: "Argument of type 'string' is not assignable to parameter of type 'number'". It does **not** quietly widen to `string | number`. The escape hatch is an explicit type argument, `pair<string | number>(1, "x")`, and the design fix is usually to give the two parameters independent type parameters, `pair<T, U>(x: T, y: U)`, if they were never meant to agree.
code
typescript · 11 linesdeclare function pair<T>(x: T, y: T): T[];
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
declare const animal: Animal;
declare const dog: Dog;
export const a = pair(animal, dog); // Animal[] — Animal covers Dog
export const b = pair(1, 2); // number[]
export const c = pair<string | number>(1, "x"); // explicit type argument settles it
// pair(1, "x"); // error TS2345go deeper
Know that repeating T across two parameters means the compiler expects both arguments to agree, and that mixing a number with a string there is a compile error rather than an automatic union.
Explain candidate collection and the best common supertype rule, and be able to predict Animal[] for a mixed Animal/Dog call and an assignability error for mixed primitives.
Read the resulting diagnostic correctly under pressure: the parameter type named in the error is the type inference chose, so trace it back to the argument that supplied the winning candidate before reaching for an explicit type argument.
Decide when repeating a type parameter is a contract worth enforcing. Signatures that force agreement catch real bugs, but they also make every caller pay when the positions are only incidentally related — that call belongs in API review.
## Candidates, not a single guess Inference is not a one-shot read of the first argument. The compiler walks every parameter position that mentions a type parameter, matches the corresponding argument against it, and collects a **set of candidates**. Only after the whole argument list has been processed does it decide what the type parameter actually is. When a type parameter appears once, the set has one member and the answer is obvious. The interesting behaviour starts when it appears twice, as in `pair<T>(x: T, y: T)`. ## The best common supertype rule Given several candidates, the compiler looks for one that all the others are assignable to — the **best common supertype** — and picks it. ```ts interface Animal { name: string } interface Dog extends Animal { bark(): void } declare const animal: Animal; declare const dog: Dog; pair(animal, dog); // Animal[] pair(dog, animal); // Animal[] — order does not matter ``` `Dog` is assignable to `Animal`, so `Animal` covers both candidates and wins. Argument order is irrelevant: the rule is about the assignability relation between candidates, not about which one was seen first. Note the direction — the compiler widens to the *supertype*, because the chosen `T` must accept every argument that was passed. ## When no candidate covers the rest ```ts pair(1, "x"); // error TS2345: Argument of type 'string' is not assignable to // parameter of type 'number'. ``` Neither `number` nor `string` is assignable to the other, so there is no common supertype among the candidates. The compiler does not invent one: it settles on a candidate and then type-checks the arguments against the resulting signature, which is why the diagnostic reads like an ordinary assignability failure at the second argument rather than "could not infer T". Understanding this makes the message legible — the parameter type it complains about is the type that inference chose, not something you wrote. The key thing to say out loud is the negative: **TypeScript does not silently form a union across inference sites.** A candidate that fails is a diagnostic, not a widening. ## The fresh-object-literal exception One case does produce a union. When the competing candidates are fresh object literals, they are combined rather than rejected: ```ts pair({ a: 1 }, { b: 2 }); // ({ a: number; b?: undefined } | { b: number; a?: undefined })[] ``` This is the same normalisation you see when you write a mixed array literal, and the optional `undefined` members are how the checker records that each branch is missing the other's property. It applies to fresh literals, not to values of declared types — mixing a literal with a value whose type is declared falls back to the ordinary rule and can fail. ## Fixing a genuine mismatch Three moves, in the order you should consider them: 1. **Was the signature right?** `pair<T>(x: T, y: T)` is a deliberate constraint — it says *these two arguments must agree*. If they were never meant to, the honest fix is two type parameters: `pair<T, U>(x: T, y: U): [T, U]`. 2. **Supply the type argument.** `pair<string | number>(1, "x")` compiles, because both arguments are assignable to the type you named. This is the escape hatch when the signature is right and you genuinely want the wider type. 3. **Widen the values.** Giving the variables a wider declared type — `const a: string | number = 1` — makes their candidates agree, which is sometimes cleaner than annotating every call. What does *not* work is annotating the result: ```ts const r: (string | number)[] = pair(1, "x"); // still error TS2345 ``` The contextual type of a call is a lower-priority inference source than the arguments, so once the arguments have supplied conflicting candidates, an annotation on the receiving variable cannot rescue them. ## The design lesson Repeating a type parameter across parameters is a real modelling decision with teeth: it makes the compiler enforce agreement, which is exactly what you want for `equals<T>(a: T, b: T)` and exactly what you do not want for a pair constructor. When a call site fights the checker with explicit type arguments, that is usually the signature telling you the two positions were never the same type. And all of this is compile-time only — the emitted JavaScript for `pair` has no `T` in it and performs no check that the two arguments match.
- Does the order of the two arguments change which candidate wins?No. The compiler compares candidates by assignability, so `pair(animal, dog)` and `pair(dog, animal)` both infer `Animal`. Order matters only for how a failure is *reported* — the diagnostic points at whichever argument did not fit the type inference settled on, which is why the same mismatch can be blamed on either position depending on how you wrote it.
- When would you deliberately keep `pair<T>(x: T, y: T)` rather than splitting it into two type parameters?When agreement is the contract you want enforced. `equals<T>(a: T, b: T)` or `replaceAll<T>(items: T[], value: T)` exist precisely so that comparing a `string` with a `number` is a compile error rather than a silently false result. Splitting into `<T, U>` throws that guarantee away, so split only when the two positions genuinely hold unrelated types.
- Why does annotating the variable that receives the result not fix `pair(1, "x")`?Because the contextual type of a call is a lower-priority inference source than the arguments. By the time the annotation could contribute, the arguments have already produced conflicting candidates and one of them has failed its assignability check. Annotations on the receiving variable only help when the arguments left the type parameter genuinely undetermined.
saying these in an interview costs you the question
- Says TypeScript unions the candidates automatically
- Claims the first argument always wins outright
- Thinks it narrows to the subtype rather than the supertype
- Believes annotating the result variable fixes the mismatch
- Assumes the argument order decides the inferred type