When Typescript infers your types incorrect, and that's okay

Here’s a little code I was writing recently

let x: number|null = null

function foo() { x = 12 }
foo()

if (x) {
  x.toString() // Property 'toString' does not exist on type 'never'.
}

When it reaches the if(x) line, Typescript thinks x is still null, and assumes the code within the block is unreachable. It infers the never type for x, and shows the error.

At first, I thought this has something to do with null type assignment. Or is it just ignoring the code within foo function ? I mean its right there, within the same scope. So I modified the code a little,

let x: number|string = "abc"

function foo() { x = 12 }
foo()

if (typeof x === 'number') {
  x.toString() // Same error
}

if (x === 12) {} // Also fails: 
                 // "This comparison appears to be unintentional because
                 //  the types 'string' and 'number' have no overlap.(2367)"

What is going on here ? Is Typescript deliberately choosing to not analyse the function foo for some reason ? Lets see if there wasn’t a function, and x was being modified inline, with some uncertainty

let x: number|null = null

if (Math.random() > 0.5) {
  x = 12
}

if (x) {
  x.toString() // No problem
}

What’s happening here

While performing the type narrowing during the control flow analysis (CFA), Typescript ignores any mutations within function invocations. So for the above example, once TS has determined that x has become string (from number|string), it does not change this inference when the function foo turns x in to a number. It could, but it does not, and that’s deliberate.

It could “enter” the function foo to analyse the types and infer the final, correct type. Or it could just revert to the original wider type (number|string). The former is impractical, and the latter is undesirable. It’s undesirable because type narrowing, even if incorrect sometimes, can help catch bugs.

There is a rather detailed discussion on Github that throws light on this: https://github.com/Microsoft/TypeScript/issues/9998. The issue has been open since July of 2016, and has invited quite a lot of comments. The top level comment by Ryan Cavanaugh and Anders Hijlsberg make their rationale clear. Some solutions have also been suggested, but none of those have been implemented.

Some more examples

// Basic Type narrowing

enum Hello { World, Universe }

function foo(x:Hello) {
  if (x === Hello.World) return      // Type narrowed to Hello.Universe 
  if (x === Hello.World) { }         // Fail. x cant be Hello.World here
}
// Ingored assignments in function calls

let str: string;
[1, 2, 3].forEach(x => {
    if (x==2) str = 'Hello'  // assignment ignored (in type analysis)
})
console.log(str)             // Fail: str used before being assigned
// Ignored assignemnt in function calls

let x: () => {};
new Promise((res, rej) => { x = res }); // assignment ignored 
console.log(x);                         // Fail: x used before being assigned
// Function call ignored in CFA

let a = [1];
if (a.length !== 1) throw "NO"     // Type narrowed here

a.push(2);                         // Ignored in Type narrowing
a.length === 2 ? "ok" : "okay"     // Fail: TS thinks the length is 1

Type narrowings are discarded on reassignments

// Discarding on reassignment

let a = [1];
if (a.length !== 1) throw "NO"     // Type narrowed

a.push(2);                         // ignored in Type narrowing
a = a                              // Type narrowings discarded. `a` is back to normal
a.length === 2 ? "ok" : "okay"     // No issue

Type narrowings (for closure variables) can also get discarded in callback functions

// Discarding in callbacks

function test3(x:string|null) {
  if (x === null) x = "";   // Type narrowed to string

  x.toLowerCase()           // works. x is string
  setTimeout(() => {        // Narrowing discarded.
     console.log(x)         // x is string | null now
     x.toLowerCase()        // Fail
  }, 1000)
}

Why does it not enter the function call ?

Because its difficult, sometimes impossible. The function code may not be available for type analysis - it may be native code, framework code etc. The CFA performance could suffer in unpredictable ways.

How do other type systems handle it

Flow, another type system for Javascript, discards all type narrowings when it encounters a function call.

function foo(arg: {x: string | null}) {
  if (arg.x === null) return;  // Type narrowed here
  arg.x.toLowerCase()          // Okay

  say("Hello")                 // Narrowing discarded
  arg.x.toLowerCase()          // Fails. arg.x is string|null again
}

The rationale here is, any function call in between has the potential to mutate the variable and change its type. For example, the say function here could get a reference to args in another part of code.

let obj = { x: "123" }
function say(msg:string) {
  obj.x = null
}
foo(obj)

To be on the safe side, Flow Type goes with a pessimistic assumption that any function call could be mutating a variable, in ways that are not direct, so we can never be sure about the types.

Typescript makes an optimistic assumption, that function call does not mutate a variable that has been type narrowed.

The fundamental problem that causes these issues is that we have mutables by default in Javascript (and call by reference). In languages that implement immutability by default, the type system is better empowered to infer types. Even more so in languages where functions are supposed to be pure.

People have suggested that annotations like pure , const , immutable , readonly etc be added to function definitions (in Typescript), so the compiler can know if a mutation is/isn’t happening. The Swift language, for example, uses a inout keyword with function parameters, to indicate that the parameter can be modified in place. (Which works for them because the default is a copy-in/copy-out system, where the function doesn’t get to mutate its parameters.) These ideas are also hard to implement within the constraints for Javascript. Discussion on the issue

What workarounds do we have

Explicit Typecasting -

let x: number|null = null

function foo() { x = 12 }
foo()

let y = x as number | null; // Explicit typecasting
if (y) {
  y.toString()
}

Reassignment (where possible): ref: a.push example above

Conclusion

Though this comes as a bad surprise while using TS, I think it is a prudent design decision. Since this saves several real bugs, while throwing out a few false errors, its a good trade-off. It also nudges us to use pure (or pure-ish) functions more - if there were no mutations, narrowed types would always be correct

Here’s an example of a bug that is prevented by type narrowing:

enum Framework { React, Angular, Vue }

function foo(x: Framework) {
  if (!x) return;        // Type narrowed x: `Framework.Angular|Framework.Vue`
                         // Framework.React discarded, as its the first value
                         // First value of enums is 0, which is falsy

  mutateX(x)                // Doesn't matter.
  switch (x) {              
    case Framework.React:   // Fail. x can't be FrameWork.React
      // Do something
      break;
    case Framework.Angular:
      // Do something
      break;
    case Framework.Vue:
      // Do something
      break;
  }
}

© 2023