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;
}
}