These two functions should have the same type ((msg?:string)=>never
) and should be functionally identical:
const fail1 = (msg?:string)=>{ throw new Error(msg); }
const fail2: (msg?:string)=>never = fail1;
Yet, fail1
fails to narrow variables, while fail2
succeeds:
declare const x: string | undefined
x.toUpperCase(); // error (as expected): 'x' is possibly 'undefined'.
if (!x) fail1();
x.toUpperCase(); // error: 'x' is possibly 'undefined'.
if (!x) fail2();
x.toUpperCase(); // OK
TypeScript is able to deduce that x
can no longer be undefined after the if (!x) fail2();
statement. However it's not able to deduce the same after if (!x) fail1();
.
Why?
You can play with my code on the TypeScript Playground.
These two functions should have the same type ((msg?:string)=>never
) and should be functionally identical:
const fail1 = (msg?:string)=>{ throw new Error(msg); }
const fail2: (msg?:string)=>never = fail1;
Yet, fail1
fails to narrow variables, while fail2
succeeds:
declare const x: string | undefined
x.toUpperCase(); // error (as expected): 'x' is possibly 'undefined'.
if (!x) fail1();
x.toUpperCase(); // error: 'x' is possibly 'undefined'.
if (!x) fail2();
x.toUpperCase(); // OK
TypeScript is able to deduce that x
can no longer be undefined after the if (!x) fail2();
statement. However it's not able to deduce the same after if (!x) fail1();
.
Why?
You can play with my code on the TypeScript Playground.
Share Improve this question asked Jan 19 at 14:25 Blue NebulaBlue Nebula 1,1027 silver badges13 bronze badges 5 |1 Answer
Reset to default 2Support for control flow analysis of never
-returning functions as introduced with TypeScript 3.7 was implemented in microsoft/TypeScript#32695. In the description of that pull request, it says:
A function call is analyzed as an assertion call or never-returning call when
- the call occurs as a top-level expression statement, and
- the call specifies a single identifier or a dotted sequence of identifiers for the function name, and
- each identifier in the function name references an entity with an explicit type, and
- the function name resolves to a function type with an
asserts
return type or an explicitnever
return type annotation.An entity is considered to have an explicit type when it is declared as a function, method, class or namespace, or as a variable, parameter or property with an explicit type annotation. (This particular rule exists so that control flow analysis of potential assertion calls doesn't circularly trigger further analysis.)
So fail1
just has an inferred never
return type, while fail2
has an explicitly annotated never
return type. The reason why this matters for control flow analysis is to avoid potentially catastrophic performance problems. (See this comment on related issue microsoft/TypeScript#45385 for an authoritative source.)
If there were no such restriction, then the type checker would need to analyze the body of a function like fail1
in order to determine if calls to it would narrow. This would depend on the results of control flow analysis inside the body of the function. There might easily be other function calls inside that function, any of which might not be explicitly annotated as well. So those function calls would trigger control flow analysis of their function bodies. This could very easily cause a cascade of analysis which either takes a very long time to complete or simply fails to complete because of circularity. To avert this sort of thing, a line needs to be drawn somewhere to say that analysis will not cross that line. They decided to put the line at the explicitness of the type of the function itself.
You might wish that they had drawn the line to allow for some kind of "shallow" control flow analysis of the function body, so that as long as the body of fail1
didn't do "too much", the analysis would be allowed. Then presumably the fact that it immediately throw
s would allow the function to be treated as an assertion of never
. But then an alternate universe version of this question would be asked where one function immediately throws, and another does something like sayHello(); throw new Error(msg);
and why does a call to the first function narrow while the call to the second function doesn't? And then when considering explaining the shallow analysis, the alternate universe question asker would wonder why the line hadn't been drawn slightly deeper.
In order to keep things tractable and somewhat sane, they made the decision where they did because it depends directly on type annotations which are discoverable in code, rather than having some harder-to-find analysis depth limit.
No, it's not great. It's caused a lot of confusion. Some of this confusion could be cleared up by more explicit documentation, but even with it, it's not obvious what's going on. Some days I think maybe this entire feature should have been withheld rather than released with such restrictive rules. Presumably alternate-universe me would complain about its absence. In this universe, the answer to your question is located at microsoft/TypeScript#32695.
const blah: never = fail1;
andconst blah2: never = fail2;
, I get error messagesType '(msg?: string) => never' is not assignable to type 'never'.
andType '(msg?: string | undefined) => never' is not assignable to type 'never'.
. So it looks like TypeScript doesn't thinkfail1
andfail2
have the same type, but I don't know why that would affect anything. – user2357112 Commented Jan 19 at 14:52fail1
to sayconst fail1 = (msg?:string|undefined)=>{ throw new Error(msg); }
, the type discrepancy seems to go away, but the narrowing difference remains. So this might not be related to the problem. – user2357112 Commented Jan 19 at 14:57(msg?: string) => never
. I don't see how the input parameters' type could affect narrowing in this case. But your findings may suggest that there may be hidden differences in the output type as well? – Blue Nebula Commented Jan 19 at 14:59msg
argument entirely, the discrepancy still remains. It looks like the explicit type onfail2
is the key. Declaring an explicit type forfail1
lets it narrow, andconst fail3 = fail2;
doesn't narrow. I still don't know why this would matter, though. – user2357112 Commented Jan 19 at 15:03never
return type is somehow different from an inferred one? – Blue Nebula Commented Jan 19 at 15:04