TypeScript's type inference uses a variety of heuristics to figure out when to widen literal types and when to leave them as narrow as possible. For example, when you write
let s = ""; // string
let n = 0; // number
let b = true; // boolean
the types of the variables s, n, and b are widened to string, number, and boolean, respectively... the assumption is that since let declarations allow you to reassign values, you probably want to do that. On the other hand, when you write
const sC = ""; // ""
const nC = 0; // 0
const bC = true; // true
the types of the variables sC, nC, and bC are kept narrow as "", 0, and true, respectively. The assumption is that because you can't change those values, you have no reason to allow more than those single values in the types.
For type parameters, like the T in your identityA() and identityB() functions, the compiler does something else. This is mentioned in microsoft/TypeScript#10676:
During type argument inference for a call expression the type inferred for a type parameter T is widened to its widened literal type if (...) T has no constraint or its constraint does not include primitive or literal types
In
declare function identityA<T extends string[]>(p: readonly [...T]): T
the type parameter T has a constraint that includes the primitive string type, and thus T will not be widened from, say, ["r", "s"] to [string, string]. On the other hand, in
declare function identityB<T extends any[]>(p: readonly [...T]): [...T]
the type parameter T's constraint does not include any literal or primitive types, so T will be widened from, say, [3] to [number]. Or since the "r" and "s" types were already non-widened from a previous inference, T will be widened from ["r", "s", 3] to ["r", "s", number].
It's confusing, right? Never mind that by using the variadic tuple type [...T] in your p parameter type you are giving the compiler a hint that you want to infer tuple types for T instead of just array types.
So that's why it happens. What can you do to fix it?
If you have control over the call site, you can use a const assertion to explicitly ask the compiler to infer the narrowest possible type for what you're passing in. This happens before T gets inferred:
const iBAsConst = identityB([...iA, 3] as const) // ['r', 's', 3]
But you asked how to change the signature to turn identityB()'s type parameter into a literal-preferring inference thingy. You can do this by changing the any[] to something that includes primitives. For example:
type Narrowable = string | number | boolean | symbol |
object | undefined | void | null | {};
declare function identityC<T extends Narrowable[]>(p: readonly [...T]): [...T]
const iC = identityC(["r", "s", 3]); // ['r', 's', 3]
Just about anything can be assigned to Narrowable, like a weird any or unknown that also works as a keep-this-narrow hint.
Hooray, right?
If that all seems like evil magic to you, I agree. A while ago I opened microsoft/TypeScript#30680 to request some syntax to explicitly ask for "as const"-like behavior for the signature side of a function. Then you'd be able to write something like
// don't try this, it won't work:
declare function identityB<T extends any[] as const>(p: readonly [...T]): [...T];
to get the same effect. Alas, it's just sitting there as a suggestion as of today (2020-09-24), so for now you have to continue to practice the dark arts to get the inference you're looking for.
Playground link to code