2

So, we've a function foo that does not specify any type for this, which means this parameter for foo is unknown (ThisParameterType<typeof foo>).

Next, we have a wrapper function that accepts two arguments and there's a generic type argument T that's used at three places.

Now, when we call wrapper with foo and a string, T is unknown for the this parameter but is a string for the arg0 parameter, but the final inference for T is string.

function foo() {}

function wrapper<T>(_cb: (this: T, ...args: any[]) => any, _arg0: T): T {
  return "" as any as T;
}

let bar = "bar"
wrapper(foo, bar)

Let's now see a similar example, where it behaves differently. Here also T is unknown at one place and number at another, but the resultant inference this time is unknown and not number.

function someFunc<T>(a: T, b: T): [T, T] {
  return [a, b];
}

let num = 10
someFunc(num as unknown, num);

I want to understand the reason behind this inconsistency?

Update based on jcalz comment:

Another is that ThisParameterType<T> is defined to return unknown if there is no this parameter, which does not mean that this is unknown;

Agreed and to verify what this actually get's inferred when it's not explicitly defined, I created the example below and if you notice you'll see that T is inferred as unknown, which means it aligns with how ThisParameterType behaves. I just mentioned ThisParameterType because adding this example would have made the question unnecessarily complicated.

function foo() { }

function wrapper<T>(cb: (this: T) => void) { }

wrapper(foo)
tsninja
  • 23
  • 4
  • 1
    There are a number of things interacting in this particular pair of examples that muddles the situation. One is that TypeScript doesn't have a `--strictThis` flag (see [ms/TS#7968](//github.com/microsoft/TypeScript/issues/7968)) so a function like `foo()` with no explicit `this` parameter will be compatible with, say, `(this: string)=>void` even though it shouldn't be. If you explicitly mark it as `foo(this: void)` then you'll get errors. Another is that `ThisParameterType` is defined to return `unknown` if there is no `this` parameter, which does *not* mean that `this` is `unknown`; ... – jcalz Sep 18 '22 at 18:17
  • 1
    ... so there is not an `unknown` in an inference site for `T`. There's only `string`. So `string` is inferred. If you explicitly mark `foo(this: unknown)` then the reason `string` is inferred has to do with the inference site being [contravariant](https://stackoverflow.com/q/66410115/2887218). For your second question you have both `unknown` and `number` appearing in covariant inference sites, and since `unknown` is a supertype of `number` the compiler infers `unknown` (since a supertype matches both and a subtype does not). – jcalz Sep 18 '22 at 18:19
  • Do those explanations make sense and fully address the question? If so I could write up an answer with sources. If not, what am I missing? – jcalz Sep 18 '22 at 18:21
  • I think saying "so there is not an unknown in an inference site for T" is not correct. I've updated my question, and the example shows that TS infers `unknown` if `this` is not explicitly defined, so why wouldn't it do that here? Which also means that both the scenarios with and without `this` explicitly defined results into `string` because of "contravarience", which I'll have to learn more about. – tsninja Sep 19 '22 at 14:00
  • To make this easier to discuss, would it be okay to define `foo` like `function foo(this:unknown) { }`? Then things behave similarly and there is definitely `unknown` to infer, as opposed to just falling back to the implicit constraint. Otherwise the question seemingly is about the lack of strict `this` parameter behavior and whether or not there's a "real" `unknown` there and it seems to be a distraction from what you really want to know. Indeed we could remove the dependence on the `this` parameter entirely and switch it to a regular parameter like [so](https://tsplay.dev/WkKRjW). – jcalz Sep 19 '22 at 14:11
  • Which shows the same variance issue, and I'd be happy to explain that. How do you want to proceed? – jcalz Sep 19 '22 at 14:12
  • Yeah, that's better, let's not worry what `this` is being inferred as. And, it'd be really helpful if you could proceed with the explanation. – tsninja Sep 19 '22 at 14:16
  • Okay I'm in the middle of writing up an answer but Stack Overflow seems to be unstable right now. I might have to finish later. – jcalz Sep 19 '22 at 17:05

1 Answers1

2

First let's look at the simple case:

let num = 10
let unk: unknown = num

function f<T>(a: T, b: T) { }
f(unk, num);
// function f<unknown>(a: unknown, b: unknown): void

In the call to f(), the type checker needs to infer the generic type parameter T from the types of values you've passed in for a and b. Another way of saying this is that the appearances of T in the a type and the b type are inference sites.

You have one value of the unknown type, TypeScript's top type, and one value of the primitive number type corresponding to just numbers. So there are two candidates from which it can choose.

What happens if it chooses number? Well that wouldn't work because while num is of type number, unk is not (even though we know it's actually a number at runtime, we've intentionally widened unk to the unknown type). Since unknown is not assignable to number, that inference would fail to type check.

What happens if it chooses unknown? That works just fine because unk is already of type unknown, and num can also be treated as having type unknown. Indeed, the whole point of the top type unknown is that all types are assignable to it.

And so the compiler chooses unknown.


Now compare to the following variation:

function unkFunc(x: unknown) { }
function numFunc(x: number) { x.toFixed() }

function g<T>(a: (x: T) => void, b: (x: T) => void) { }
g(unkFunc, numFunc)
// function g<number>(a: (x: number) => void, b: (x: number) => void): void

In the call to g(), the appearances of T in the types of a and b are still inference sites, but now they appear in positions of a function parameter. Again you have one value where T should be inferred as unknown and another where T should be inferred as number. So there are two candidates.

What happens if the compiler chooses unknown? That wouldn't work because while unkFunc is definitely of type (x: unknown) => void, numFunc is not of type (x: unknown) => void. If it were, you could call it with any value you want, like numFunc("") or numFunc({}) or numFunc(null). If you actually do any of those you'll get a runtime error, and even if you didn't (if I left out the x.toFixed() line), it would be wrong because numFunc's call signature requires that its parameter be of type number. And since unknown is not assignable to number, that inference would fail to type check. assignable to number, that inference would fail to type check.

What happens if it chooses number? That works just fine because numFunc is already of type (x: number) => void, and unkFunc can also be treated as having type (x: number) => void. It is perfectly safe to treat a function that accepts anything as one that only accepts numbers. The call unkFunc(10) is fine.

And so the compiler chooses number.

Note how the direction of inference and type checking for function parameters is opposite to that of plain values. In other words, ((x: A) => void) extends ((x: B) => void)if and only ifB extends A. The type of a function varies *counter* to the type of its parameter. In other words, functions are **contravariant** in their parameter types. The inference sites in g()are in "contravariant positions", whereas those inf()` are in covariant positions (because they vary the same way as the type you're trying to measure... they co-vary).

See Difference between Variance, Covariance, Contravariance and Bivariance in TypeScript and the Wikipedia entry on variance for a general discussion about variance, and the release notes for --strictFunctionTypes which also discusses contravariance of function parameters.


Your foo() example is essentially this, although there are some details that make it harder to see. One detail is that one inference site is contravariant and the other is covariant. That means it would turn out to be fine no matter which of number or unknown gets inferred:

function h<T>(a: (x: T) => void, b: T) { }
h<number>(unkFunc, num); // okay
h<unknown>(unkFunc, num); // okay

Of course when you actually call it, you get number and not unknown, and you still might want to know why:

h(unkFunc, num); 
// function h<number>(a: (x: number) => void, b: number): void

That's because inference sites have different priority (see ms/TS#14829 for a related issue in which inference site priority is discussed). Roughly speaking, because (x: T) => void is a more complex type than T, the compiler gives more priority to the simpler inference site. So will tend to infer from b and not from a. Since the number candidate from b works, that's what you get.

Another detail is that you're using a virtual this parameter instead of a regular parameter, but functions are still contravariant in their this context:

function unkThisFunc(this: unknown) { }
function i<T>(a: (this: T)=>void, b: T) {}
i(unkThisFunc, num);
// function i<number>(a: (this: number) => void, b: number): void
i<number>(unkThisFunc, num); // okay
i<unknown>(unkThisFunc, num); // okay

And thirdly you are not actually specifying the this parameter, and it gets tricky to say whether we should treat this as being implicitly unknown or whether there isn't actually a candidate present for that inference site, which would end up falling back to the implicit unknown generic constraint, but again, you get the same behavior:

function implicitUnkThisFunc() { }
i(implicitUnkThisFunc, num);
//function i<number>(a: (this: number) => void, b: number): void
i<number>(implicitUnkThisFunc, num); // okay
i<unknown>(implicitUnkThisFunc, num); // okay

But backing way up, I think the important bit to understand here is that you can assign number to unknown but not vice versa, and you can assign (x: unknown) => void to (x: number) => void but not vice versa. Armed with that, it makes sense that number is a valid inference candidate when the parameter type is unknown. You might still wonder why number is chosen over unknown, but the fact that number is valid should no longer be a concern.

Playground link to code)

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Beautifully explained, all of this makes so much sense now! Thanks :) – tsninja Sep 20 '22 at 09:42
  • One more request, there's another really interesting recently asked TS question [here](https://stackoverflow.com/questions/73669925/why-does-adding-a-constraint-on-a-generic-type-change-the-inference-behavior), can you please take a look at that, I'm unable to understand how TS decides when to widen and narrow a generic type argument. There's already an answer but it's not that detailed. – tsninja Sep 20 '22 at 09:42
  • 1
    The answer there looks right to me... `T extends string` has a constraint including the `string` primitive type, so the compiler sees it as a hint not to widen the string literal type, whereas unconstrained `T` doesn't, so it is widened. It's mostly written out in [ms/TS#10676](https://github.com/microsoft/TypeScript/pull/10676) – jcalz Sep 20 '22 at 12:55