4

Here's the code (Playground Link):

interface XY {x: number, y: number}

function mcve(current: XY | undefined, pointers: Record<string, XY>): void {
    if(!current) { throw new Error(); }
    
    while(true) {
        let key = current.x + ',' + current.y;
        current = pointers[key];
    }
}

The code in this example isn't meant to do anything useful; I removed everything that wasn't necessary to demonstrate the issue. Typescript reports the following error at compile-time, on the line where the variable key is declared:

'key' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.

As far as I can tell, at the start of each iteration of the loop, Typescript knows that current is narrowed to type XY, and that current.x and current.y are each of type number, so it should be straightforward to determine that the expression current.x + ',' + current.y is of type string, and infer that key is of type string. The way I see it, a string concatenation should obviously be of type string. However, Typescript does not do this.

My question is, why isn't Typescript able to infer that key is of type string?


In investigating the issue, I've discovered several changes to the code which cause the error message to go away, but I'm not able to understand why these changes should matter to Typescript in this code:

  • Giving key an explicit type annotation : string, which is what I've done in my real code, but it doesn't help me understand why this can't be inferred.
  • Commenting out the line current = pointers[key]. In this case key is correctly inferred as string, and I don't understand why the subsequent assignment to current should make this harder to infer.
  • Changing the current parameter's type from XY | undefined to XY; I don't understand why this should matter, since current does have type XY at the start of the loop by control-flow type narrowing. (If it didn't then I'd expect an error like "current could be undefined" instead of the actual error message.)
  • Replacing current.x and current.y with some other expressions of type number. I don't understand why this should matter, since current.x and current.y do have type number in that expression.
  • Replacing pointers with a function of type (s: string) => XY and replacing the index access with a function call. I don't understand why this should matter, because an index access of a Record<string, XY> seems like it should be equivalent to a function call of type (s: string) => XY, given that Typescript does assume the index will be present in the record.
kaya3
  • 47,440
  • 4
  • 68
  • 97
  • Not at a real computer now, but I suspect that this is a limitation of the type inference algorithm; the dependency marking probably happens in an order that makes this a pathological case: the type of `key` depends on `current `; the type of `current` can change due to CFA narrowing, so its type depends on `pointers[key]`’s type, which is an apparent circularity. That the compiler cannot “see” that the circularity is removable by evaluating types in a different order is regrettable but probably shouldn’t be too surprising. – jcalz May 23 '21 at 15:37
  • Thanks for your comment @jcalz - I don't see how the type of `key` can depend on the type of `current`; whatever `current` is, the string concatenation will either result in a string, or an exception being thrown by `current.x` (if `current` is `undefined`) before `key` ever receives a value. – kaya3 May 23 '21 at 16:33
  • I believe you should create an issue. Behavior is a bit strange. If you will create an issue pls dont forget to add link I will subscribe – captain-yossarian from Ukraine May 23 '21 at 16:34
  • 1
    @kaya3 again, you're looking at an expression like `current.x + ','` and doing a "short circuit" type analysis of the form `??? + string → string` where `???` can be completely ignored. But the compiler doesn't do that short circuit; it won't realize that until it evaluates the types of both operands to `+` and then resolves which of the various "overloads" of `+` apply to those two types. And so it falls down the hole of circularity before ever reaching the next step. – jcalz May 24 '21 at 01:57
  • I encountered the same error in another case where the type analysis can be short circuited. I have a class `Widgets` (collection) and a class `Widget` (instance). Inside the collection class, instance classes are created with a reference to their collection. A class constructor usually returns an instance of the class. That is true in my case as well and TS knows about this because the type is `constructor Widget(name: WidgetName, parentStore: WidgetStoreType): Widget` but still it errors when I do `[name] = new Widget([name], this)` so I have to `[name]: WidgetType = (...)` – ThaJay Feb 27 '23 at 14:24

1 Answers1

3

See microsoft/TypeScript#43047 for a canonical answer to this sort of problem.

This is a design limitation of the TypeScript type inference algorithm. Generally speaking, for the compiler to infer the type of a variable x given an initialized assignment to x, it needs to know the type of the expression being assigned. And if that expression contains references to other variables whose types have not been explicitly annotated, it needs to infer types for those variables too. If this chain of dependencies ever comes back to x before being resolved, the compiler just gives up and declares that x is referenced in its own initializer.

In your case, I imagine that the compiler's analysis goes something like this (I am no compiler expert so this is just meant to be illustrative, not canonical):

  • The type of key depends on the type of current.x + ',' + current.y, which depends on the type of current.x + ','
  • The type of current.x + ',' depends on the type of current.x and the type of ','
  • The type of current.x depends on the type of current.
  • Since current is a union-typed variable, its apparent type can be narrowed via control flow analysis, and so its type at the point where key is assigned is dependent on any prior such narrowings, such as the assignment current = pointers[key] which may have occurred at the end of a previous loop.
  • The type of pointers[key] depends on the type of pointers and the type of key.
  • The type of pointers is annotated to be Record<string, XY> and is not narrowed via control flow analysis, so we can stop looking here.
  • The type of key depends on... hey wait a minute, CIRCULARITY DETECTED!

It's not desirable compiler behavior by any means. But it's not really a bug in TypeScript because key's initializer references current and the second time through the loop current has an assignment that references key. So key does indeed indirectly reference itself in its initializer... and this is pretty solidly "design limitation" territory.


Of course, at many of these above bullet points, a reasonable human being may well differ in behavior from the compiler. For example, consider

  • The type of current.x + ',' depends on the type of current.x and the type of ','

While generally speaking it is true that the type of an expression of the form a + b depends on the type of a and the type of b, there are some particular types for a (or b) that mean you can "short-circuit" the type analysis and completely ignore the type of b (or a). In the case above, since current.x + ',' is adding a string to current.x, the result will definitely be a string no matter what current.x turns out to be.

Unfortunately the compiler does not do such analysis here. Maybe someone could open an issue in GitHub asking for such, but I don't know that it would be implemented. It's possible that such extra checks for "short-circuitable" expressions could, on balance, pay for themselves in terms of compiler performance. But if it degrades the average performance of the compiler, then the cure could be worse than the disease. It would be interesting to see such a feature request, and I'd definitely give it a , but I wouldn't be very optimistic about it being adopted.


Anyway, the changes you talk about in your question disrupt some part of the above chain, and prevent the compiler from falling down the circularity hole. Explicitly annotating key as string is the obvious fix, and enables the compiler to now just check the type of key instead of inferring it. When it again arrives at key in current = pointers[key], it knows that key is a string and can just move on.

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thanks for the detailed answer. I originally had this as ``let key = `${current.x},${current.y}`;`` and this had the same problem exactly; I'm surprised that the compiler thinks the type of a string interpolation could ever be anything other than `string`, but perhaps it is analysed the same way as a concatenation, and then the ambiguity of "maybe this + means numeric addition" is spuriously introduced? Either that or the compiler considers that a string interpolation could have a stricter type than `string` in some circumstances. – kaya3 May 24 '21 at 04:15
  • I guess that for both `+` and template literals, the compiler is treating them as expressions depending on constituent expressions, and not looking ahead to see whether the output type can be determined without evaluating the input types. For template literals I'd have thought that it would just assume `string` output without caring about the input, but I guess not. Maybe they just happened not to implement that, or it could be a performance thing, or maybe it's trickier to do so because general template literal expressions include tagged templates which can have any output type. I'm curious. – jcalz May 24 '21 at 13:51
  • If only the error could tell you where to look. – trusktr Mar 29 '22 at 21:12