TypeScript: Literal type narrowing regression
TypeScript Version: typescript@2.1.0-dev.20160913
Code
type FAILURE = "FAILURE";
const FAILURE = "FAILURE";
type Result<T> = T | FAILURE;
function doWork<T>(): Result<T> {
return FAILURE;
}
function isSuccess<T>(result: Result<T>): result is T {
return !isFailure(result);
}
function isFailure<T>(result: Result<T>): result is FAILURE {
return result === FAILURE;
}
function increment(x: number): number {
return x + 1;
}
let result = doWork<number>();
if (isSuccess(result)) {
increment(result);
// ~~~~~~
// Argument of type 'string | number' is not assignable to parameter of type 'number'.
// Type 'string' is not assignable to type 'number'.
}
Expected behavior:
result(before the narrowing) should be"FAILURE" | numberresult(after the narrowing) should benumber
Actual behavior:
result(before the narrowing) isstring | numberresult(after the narrowing) isstring | number
About this issue
- Original URL
- State: closed
- Created 8 years ago
- Comments: 24 (20 by maintainers)
@wallverb I’ll think about the suggestion of showing
widening(or some other indicator) in the hints. In general I’m not crazy about showing syntax that isn’t supported in the language, and I don’t think the distinction merits elevation to an actual type modifier.@wallverb Yeah, I specifically tried to capture as many subtleties as possible in a short example, and it definitely adds another layer to the rules. I think the intuition is reasonably simple though–as long as an explicit type annotation hasn’t been encountered for a particular literal in an expression, that literal type will widen to
stringwhen inferred for a mutable location.Oh, one last thing. A very simple way to understand the confusion Godfrey had initially was that these two results seem incoherent and surprising:
My TLDR:
constandletto produce different types for the same rhs expression (especially in the only-assigned-at-declaration let case).letmutationletmutation of literal subtypes to diverge from other kinds of subtypes is creating more problems than it solved.Onward:
@mhegazy I’m quite a bit confused about the way you’re applying the stated principles to this problem. Among other things, I’m pretty concerned about so many special rules applied to literal subtypes that are not applied to other subtypes. I’ll get to that in a bit.
In order to make this work, you needed to define “its symbolic name” as “a name declared as a const binding”.
In particular, this doesn’t work:
In this case, event is a symbolic name that we have declared as returning an Event (which is an abstraction over the precise literals we’re talking about). We have other functions that also take
Events, but because our “symbolic name” is technically mutable (even though we haven’t mutated it), this code fails to type check.The fact that
const event = onmouseover()would be the only change needed to make the same code type check is incredibly unintuitive.I agree with and accept the constraint about symbolic names, but I think a TypeScript user’s plausible intuition doesn’t differentiate between
constand assigned-only-at-initializationlet. Try to look at the above example with fresh eyes and see if it feels intuitive to you.Seems reasonable, but perhaps not a hard constraint, given how tricky this problem is getting.
In particular, one approach that gets at the heart of the problem is to have
.d.tsgenerators always emit the wider type if one is not specified. Because we have an opportunity to decide what should happen when we generate a.d.ts, and because we all agree that the inferred return type of a function shouldn’t change based on usage, I don’t think there’s any problem with assuming that inferred types are always wider, while explicitly declared types are as-declared.I think this is too loose of a description of the semantic questions. Identified by who, in what context? Do you mean “when hovering over a variable in the IDE”? Or do you mean “once a function has a return type, it shouldn’t change”?
In particular, I strongly agree with the decision that the TS made to avoid cross-function inference. So I agree that identifying the type of a function should always be possible, and once identified should not be changed unless the function body has changed.
I also agree that the type of a declared variable should never change from one type to an unrelated one. However, it is not at all obvious to me that those sources of agreement imply that automatic widening within a single function body should be disallowed. That question depends a lot on expected intuitions, as well as how effective we can make our error messages at guiding people in the right direction.
I simply disagree that this is an “intuition”. I would accept that it might be “a heuristic we can teach people”, but I strongly disagree that this behavior is something that somebody would intuit without instruction and memorization.
I also disagree that literal types are not very useful in these conditions, especially when type aliases are used:
In this case, the literal is pretty useful and intuitive, and it’s surprising that
eventbecomesstringsimply because I usedlet.With all that said, I’d like to take another tack at expressing my concern about giving literals special semantics.
Here’s an example that’s pretty similar conceptually to the problem:
As in the string literal situation:
document.createElementto return anElement, just as a human might expect a string to bestringHTMLDivElementis a subtype ofElement, just as"onmouseover"is a subtype ofstring[ts] Type 'HTMLSpanElement' is not assignable to type 'HTMLDivElement'. Property 'align' is missing in type 'HTMLSpanElement'.) just as a human author might find the string error confusing.HTMLDivElement(if they were trying to usealign😉) just as a user might well want precisely an"onmouseover"(if calling a function that expects it).A great part of the reason that this doesn’t matter as much as expected in practice is that mutation to a different subtype of the same supertype is relatively rare. In the absence of mutation, passing the subtype to a function that expects the supertype works as expected.
Finally, I think it’s important to acknowledge that the primary scenario driving the decision to treat
constandletdifferently is this:First, some examples to illustrate why it’s the driving scenario:
However:
I think this is definitely a concern worth thinking about, because it’s absolutely true that most people wouldn’t expect the narrower type in this scenario.
However, I think solving it by differentiating between
letandconstis an overly broad hammer to attack this problem with.Remember that the same problem, with the same intuitive hurdles, exists for other kinds of types:
We could use the same logic about mutable locations to argue that
let xshould produce a wide type (maybe the most general subtype of Object) and that users should writeconst xif they want the narrow type, but we don’t (and I don’t think we should either).The most natural way to address the various constraints is to respect the types that users wrote down and use wider types if no narrower type is ever specified.
This addresses any scenario where an explicit type was declared for a literal anywhere:
We can now address the exact literal case much more narrowly, with one of the following solutions:
Option 1.
constand “only-assigned-at-initializationletto a literal” uses the narrower type, while actually-muatedletuses the wider type. The user can use a type ascription on the originalletto get a narrower type if they’re mutating thelet.Option 2. all
constandletto a string literal use the narrower type, and if the user mutates aletvariable to a different subtype of the same primitive type, we instruct them to add: stringto the original declaration (I can see why this might seem annoying, but it’s consistent with how we handleElement, not so common, and a good error would go a long way).Option 3. use internal-to-function-only inference to give mutated
letvariables a union type of all of the string literals that the variable is assigned to.Important: any type produced by an abstraction gets the explicit return type specified by the function.
function() { return "foo"; }has the inferred return typestring.I want to flesh out Option 3 because it has some non-obvious properties:
The interesting thing about these semantics are:
letcan always be accomplished by unioning the type of all direct assignments to the mutable variable"foo"and"bar"are the only assignments to a mutable variable, its type is"foo" | "bar"). But in all cases, it is possible to determine the type of such a variable with a single hop to a function or slot with a fixed type.constis as desired (const x = "foo"->const x: "foo" = "foo"), but it produces a shallower cliff tolet.There is also an alternative variant of these semantics that might be be even easier to implement and satisfy more constraints than the current design (but still have some pitfalls):
Applying the same inductive rules to this variant allows explicit types to be respected, and avoids a semantic difference between
letandconst(becauseconst x = "foo"will trivially satisfy the string literal restriction). The main caveat is thatlet x = "Hamburger"; x = hotdog();would producex: string, but that’s a rather minor caveat compared to the much larger cliffs we’re facing today.I apologize for how long this reply has gotten – I didn’t intend to write such a big a wall of text initially, and that may account for some incoherence between the beginning and ending of the comment. Please ask for clarifications or further fleshing out if something is confusing.
Some principals/intuitions that were involved in this change:
func("foo")toconst foo = "foo"; func(foo);should behave the samevar x = 1be a number.let, array element, or property declaration) are not literal type locations in general, mainly as literal types are not very useful in these conditions. e.g.var x = 0;assuming that the type ofxis0is not very useful, and most likely the user intent is to havexbe anumber.With these all in perspective, I believe the position we are in now is much better than where we were. there are some breaking changes that this entails, but overall the system is better positioned.