Repository navigation
Inconsistent narrowing in arrow function #32300
Description
Activity
MartinJohns commented
on Jul 8, 2019 ContributorMore actionsAlso:
const state: State = { isOpen: false }; state.isOpen = true; // Type 'true' is not assignable to type 'false'.I believe that would fall under #31734 which relates to what type
statewould be here. This issue is specifically about similar consumptions of the same type having different types.- changed the title
[-]Inconsistent narrowing for const union in arrow function[/-][+]Inconsistent return type widening in arrow function[/+]on Jul 8, 2019 - changed the title
[-]Inconsistent return type widening in arrow function[/-][+]Inconsistent narrowing in arrow function[/+]on Jul 8, 2019 - addedQuestionAn issue which isn't directly actionable in codeAn issue which isn't directly actionable in code
on Jul 8, 2019 The root cause is that control flow narrowing applies to arrows and function expressions. It's not unique to arrows:
const fexpr = function () { return state }
Now
typeof fexpr === typeof arrow.The reason is that function and class declarations are hoisted -- so control flow analysis doesn't apply because the compiler isn't sure when they will run. They use the declared type of
state: State. However,() => stateis not hoisted, and (optimistically) captures the narrowed type ofstate: ClosedStateat the point that it is captured.Reacted by Ryan Cavanaugh, Matt Tingen and awmleer- addedWorking as IntendedThe behavior described is the intended behavior; this is not a bugThe behavior described is the intended behavior; this is not a bug
on Jul 8, 2019 Thanks for the explanation, Nathan Shively-Sanders (@sandersn). It makes sense to me that hoisted values would be handled differently, but I don't 100% understand this difference. I need to sit down and formulate some follow-up questions. Is this an acceptable place to ask them or should I create an SO question for follow-up?
Also, does the fact that hovering over
stateinconst state(in the playground, vscode, etc) showsstate: Stateseem like expected behavior in this scenario?This is an OK place for follow-up since there's discussion here already, but use SO as the default for future questions.
const statedisplays the declared type, which is the type pre-assignment. I think this makes sense because you're asking for the type at the declaration, after all.class declarations are hoisted
I know this isn't really relevant to the issue but: are they? This is a runtime error, e.g.:
new C(); // ReferenceError: Cannot access 'C' before initialization class C {}
I guess the
Cidentifier itself is hoisted, but the class itself is not. Which makes sense because you can use arbitrary expressions in theextendsclause.Right, just as with
varandfunction, the declaration is hoisted, but not the initialization. Your example is analogous to:let C; new C(); C = class C {};
Right, but
functionis fully hoisted: You can safely call the function before it's "in scope".fn(); // hunky dory function fn() { console.log("Hello world!"); }
Which is why your
fnis treated differently fromarrow.Yep, my mistake about the
functionhoisting.Nathan Shively-Sanders (@sandersn), looking at this a bit more, I'm still not clear on the originally discussed typing difference. If it's a limitation, I can understand, but it's not clicking for me why it would be intended behavior. To be clear, my intention is not to nit over this issue's classification, but to understand TS at a deeper level.
More specifically, I'm getting caught up on "control flow analysis doesn't apply because the compiler isn't sure when they will run". If
fnwere invoked prior toconst state, it would throw. If invoked after, I would expect it to have the narrowed return type since it's no longer "taking advantage of hoisting" so to speak and would behave the same as a function expression. Am I missing something in thinking that the uninitialized declaration of aconstcannot be accessed?In short, I don't see any cases where
statecan actually be used asStatewithout being guaranteed to throw.Additionally, as Bruce Pascoe (@fatcerberus) pointed out, class implementations are not hoisted so it seems like they would be treated in a similar manner to arrow/function expressions.
I’m actually a bit confused myself because I had thought local narrowings were always discarded at function boundaries, except for IIFEs where the narrowings are retained inside the function body for obvious reasons. Your case doesn’t involve an IIFE so I’d have expected even the arrow function to automatically widen to
State.RyanCavanaugh commented
on Jul 10, 2019 MemberMore actionsThe underlying question here is whether, in a given position, TS should use the declared type or the control-flowed type.
For a declaration, which inherently does not belong in a control flow graph, it doesn't make any sense to talk about the control-flowed type, because it has no place in the graph from which to compute which narrowings are in effect. The code inside those declarations can run at any "time" (including times at which a
constis TDZ'd, for example)This isn't the case for an expression that initialized a
const- this initialization takes place at a knowable time in the control flow graph, and it makes sense to use the control-flowed type. Here we don't widen because it's aconstbinding; if you changeconsttoletyou'll see thatarrowhas type() => State.You can call these behaviors inconsistent, which they are, except that different code really should cause different behavior. "For consistency, this program should be treated identically to this nearly-equivalent program" is always at most two hops away from a paradox
Thanks, Ryan, that helped to bring the issue into focus a bit more.
Now I understand why accessing a
letin a hoisted scope would use the declared type for thatlet; there's any number of narrowings that may occur after declaration and the hoisted scope could be invoked after any (or none) of them, so we just have to use the widest type which is the declared type.For
const, though, it still seems to me that the widest type would be the narrowed initialization. Sure, the hoisted function could be called while theconstis in the TDZ, but wouldn't the type of theconsteffectively be the narrowed type| never(which simplifies to the narrowed type)? The declared type may be the most correct technically but I'm failing to see how it's of any use practically.RyanCavanaugh commented
on Jul 10, 2019 MemberMore actionsIt's really unknowable which behavior you "wanted" when you
typeofan expression from a place that isn't in the same control flow graph as the originating expression. We conversely sometimes get bugs that people don't want the control-flowed type when usingtypeofin a control-graphed position, for example, so it's not something that is clearly settled.I think more to the point, if you wrote
const x: T = e;
instead of
const x = e;
when
Tis a supertype oftypeof e, there is some intent communicated by that - disregarding the type annotation when outside the control flow graph is arguably actively discarding intent written by the program's author.disregarding the type annotation when outside the control flow graph is arguably actively discarding intent written by the program's author.
I agree with this, but the correlary to that is: why should
const x: T = ...be doing any narrowing to a subtype ofTin the first place; I think that’s the more surprising behavior to me. If I declare itconstwith an explicit type, I’d personally expect it to always be exactly the type I said it was, even if there’s something more specific that matches.RyanCavanaugh commented
on Jul 10, 2019 MemberMore actionsPeople write code like this and expect it to work:
type Method = "GET" | "POST" | "PATCH"; declare function doSomething(x: "GET" | "POST") { ... } // later const x: Method = "GET"; doSomething(x);
It'd be really weird to have
constbehave worse thanletin this regard, since you definitely want narrowing based on assignments tolets. It'd also be a new and worse inconsistency to have initializations be ignored but not assignments.Reacted by Bruce PascoeI disagree that there's necessarily intent there.
I often writeconst x: T = ebecause it provides code completion and verifies against typos in ways that an assertion would not necessarily catch e.g.interface Foo { bar?: number } const foo = { baz: 3 } as Foo // Compiles; allows for typo const foo2: Foo = { baz: 3 } // Error; catches typo
Perhaps I need to be doing both.
#7481 would also remedy this if it were implemented with a prefix notation.
FWIW, I was also surprised that
const x: T = ...narrows. IMO, that is #31734, though it is highly related to this.It was particularly surprising that hovering over
const xdisplays what is a seemingly unusable type.typescript-bot commented
on Jul 13, 2019 ContributorMore actionsThis issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.
- locked as resolved and limited conversation to collaborators
on Oct 21, 2025
TypeScript Version: 3.6.0-dev.20190704
Search Terms: inconsistent union narrow const initialization arrow function
Code
Expected behavior:
Arrow === Fn === Method === BoundMethodWhat that type should be is #31734
Actual behavior:
Arrow === () => ClosedStateFn === Method === BoundMethod === () => StatePlayground Link: Playground
Related Issues: #31734, #8513, possibly dupe of #29260 but I don't think so
A couple things to highlight:
const statetolet stateachieves the expected behavior with a value ofState.const stateindicates that it isState, even thoughtypeof state === ClosedState. I did not create a separate issue for that because it seems likely that that is a symptom of this issue rather than a distinct issue with the language server.