Why does Typescript say this variable is “referenced directly or indirectly in its own initializer”?

Total
1
Shares

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.

Solution

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.

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 try, 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.

Leave a Reply

Your email address will not be published. Required fields are marked *