-
Notifications
You must be signed in to change notification settings - Fork 12.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Literal type narrowing regression #10898
Comments
This is working on 20160911 but broke in 20160912, most likely related to #10676 |
It seems like the change is somewhat intentional – that |
the change is intentional. see #10676 for more details. I do not think this is a pattern we explicitly thought about; so we need to go back and talk about this. a work around would be an explicit type annotation to result: |
//cc: @ahejlsberg |
The issue here is that when we infer the type One possible solution is to change the first two lines to: type FAILURE = "FAILURE" & {};
const FAILURE: FAILURE = "FAILURE"; This turns the type FAILURE = { __kind__: "FAILURE" };
const FAILURE: FAILURE = { __kind__: "FAILURE" }; |
I agree the last solution would work and arguably better, but there are still a few things that doesn't feel right to me:
|
Consider this modified example: function doWork<T>(x: T): Result<T> {
return x;
}
const c = doWork(1); // Result<1>
let v = doWork(1); // Result<1> widened to Result<number> In both cases The modified example above works when
What happens is that when inferring from |
I'm re-labeling as Breaking Change as this is working as intended and is an effect of our new approach to better preserving literal types. |
I was thinking about this issue and what dawned on me is that maybe it will be most intuitive if explicit literal, non-template types are not widened. It's subtle, but the purpose of the union is that the literal options are something you expect and prepare to discriminate on even in mutable locations. Maybe I'm wrong, but I wasn't able to come up with a counter example. |
Some principals/intuitions that were involved in this change:
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. |
My TLDR:
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: type Event = "onmouseover" | "onmouseout";
function onmouseover(): Event { return "onmouseover"; }
function onmouseout(): Event { return "onmouseout"; }
function register(event: Event, callback: DOMCallback) { window.addEventListener(event, callback); }
let event = onmouseover();
register(event, e => { console.log(e); }); 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 The fact that I agree with and accept the constraint about symbolic names, but I think a TypeScript user's plausible intuition doesn't differentiate between
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
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: type Event = "onmouseover" | "onmouseout";
function onmouseover(): Event { return "onmouseover"; }
function onmouseout(): Event { return "onmouseout"; }
function register(event: Event, callback: DOMCallback) { window.addEventListener(event, callback); }
let event = onmouseover();
if (someCond) {
event = onmouseout();
}
register(event, e => { console.log(e); }); In this case, the literal is pretty useful and intuitive, and it's surprising that 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: function text(text: string, inline = false) {
let el = document.createElement('div');
if (inline) {
el = document.createElement('span');
}
el.innerText = text;
} As in the string literal situation:
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 let x = "hello";
if (someCond) {
x = "goodbye";
} First, some examples to illustrate why it's the driving scenario: function print(s: string) {
console.log(s);
}
function hello(h: "hello") {
helloworld(h, "world");
}
let x = "hello"; // let's assume we make x: "hello"
print(x); // type checks, because "hello" is a subtype of string
hello(x); // type checks, because "hello" is "hello" However: let x = "one";
if (someCond) {
x = "two"; // we think it's problematic/unintuitive for this to be a type error
} 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 Remember that the same problem, with the same intuitive hurdles, exists for other kinds of types: let x = document.createElement('div');
if (someCond) {
x = document.createElement('span'); // we aren't as concerned about making this a type error
// [ts] Type 'HTMLSpanElement' is not assignable to type 'HTMLDivElement'.
// Property 'align' is missing in type 'HTMLSpanElement'.
} We could use the same logic about mutable locations to argue that 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: function onmouseover(): Event { return "onmouseover"; }
let x = onmouseover(); // x: Event because that's what the function said We can now address the exact literal case much more narrowly, with one of the following solutions: Option 1. Option 2. all Option 3. use internal-to-function-only inference to give mutated Important: any type produced by an abstraction gets the explicit return type specified by the function. I want to flesh out Option 3 because it has some non-obvious properties: // inferred return type: `string`
function hello() {
return "string";
}
// inferred return type: `string`
function world() {
return hello();
}
type Food = "Hamburger" | "Hot Dog" | "Salad";
function burger(): Food { // return type Food
return "Hamburger";
}
function hotdog(): Food { // return type Food
return "Hot Dog";
}
// inferred return type: Food, from burger()
// note: inferred return types are always fixed, but they are also inductive
function hamburger() {
return burger();
}
function eat(food: Foo) {
}
let food = burger(); // inferred type: Food, supplied by burger();
eat(food); // type checks
function main() {
let food = burger(); // inferred type: Food
if (Math.random() > 0.5) {
food = hotdog(); // type checks, food: Food, and hotdog(): Food
}
let name = "Yehuda"; // inferred type: "Yehuda" | "YEHUDA";
if (Math.random() > 0.5) {
name = "YEHUDA";
}
// inferred type: "TypeScript" | Food
let language = "TypeScript";
if (Math.random() > 0.5) {
language = burger();
}
// inferred type: string
let author = "Anders";
if (Math.random() > 0.5) {
author = hello(); // reminder -> hello(): string
}
} The interesting thing about these semantics are:
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 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. |
Oh, one last thing. A very simple way to understand the confusion Godfrey had initially was that these two results seem incoherent and surprising: let x: Event = foo();
// works differently from
let x = foo(); // where foo() is explicitly defined as returning Event |
I still think that mutability widening tends to have surprising characteristics in a lot of instances like this. The idea that types don't transitively flow through declarations feels odd. We should ensure we keep an eye out for any more feedback on this. |
@chancancode @wycats We have a fix for this in #11126. Explicit type annotations are now respected and your examples behave as expected. Thanks for the well considered feedback (even if it did arrive as a wall of text 😃). |
I'm glad that #11126 helps with examples above - but wow - rules got complicated now. One need to think now in terms of literal types and widening literal types and combination of them and some cases that cancel the widening if there is non-widening form that gets priority :) This example declare let cond: boolean;
const c1 = cond ? "foo" : "bar";
const c2: "foo" | "bar" = c1;
const c3 = cond ? c1 : c2;
const c4 = cond ? c3 : "baz";
const c5: "foo" | "bar" | "baz" = c4;
let v1 = c1; // string
let v2 = c2; // "foo" | "bar"
let v3 = c3; // "foo" | "bar"
let v4 = c4; // string
let v5 = c5; // "foo" | "bar" | "baz" reminds me one of this famous CoffeeScript question - "What is the result of :" foo bar and hello world where users might need to compile the code first to check if it is I wish there was a way to satisfy both:
One request though would be to add support to and one could be surprised that so instead of const c4: "foo" | "bar" | "baz" if it could show: const c4: "foo" | "bar" | widening "baz" Thanks a lot for awesome TS |
@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 |
@wallverb I'll think about the suggestion of showing |
I find it really unfortunate that now more annotations are needed for use-cases where the implicit intent is to use a literal union as a type. I can't formulate a formal proposal, but my strong feeling is that wherever an explicit literal type annotation is given, it should be preserved regardless of mutable or immutable locations. For example: type Rel = 'lt' | 'eq' | 'gt';
function compare(x: number, y: number): Rel {
return (x === y) ? 'eq' : (x > y) ? 'gt' : 'lt';
}
const cmpRes = compare(1, 2); // `Rel`, ok
let cmpResMut = compare(1, 2); // `string`, what? when did I say that?
// for what reason will I ever assign 'hello' to a comparison result? The second widening is like widening a subclass to the base or any value to Object (as @wycats pointed out). It allows for unintended values to be assigned and is arguably not intuitive. If this was the intent, than a natural (and a bless for the reader/maintainer) solution is to add the widened annotation by hand. The same applies to object literals: const x = {
left: 1,
right: 2,
cmp: compare(1, 2) // why string?
}
class A {}
class B extends A {}
const y = { instance: new B() }; // it is correctly `{ instance: B }` As a side note, this is also a regression in the display of types in IDEs, |
Well if that's the case then it's good news, I couldn't get it from the examples. I still have the feeling that apparent literal types are inferred less often after #10676. With #11126 we'll have a mechanism for forcing them at the expense of verbosity (and unfortunately they are not transitive :(). |
type Rel = 'lt' | 'eq' | 'gt';
function compare(x: number, y: number): Rel {
return (x === y) ? 'eq' : (x > y) ? 'gt' : 'lt';
}
const cmpRes = compare(1, 2); // `Rel`, ok
let cmpResMut = compare(1, 2); // `string`, what? when did I say that?
// for what reason will I ever assign 'hello' to a comparison result? Unless I understand wrong, both |
@gcnew Not sure what you mean by not transitive. One thing is certain though: With #10676 and #11126 in place, we'll never infer less specific types for string and numeric literals than we used to. Specifically, we behave just like before when you have explicit type annotations, and whereas we used to immediately widen un-annotated |
My apologies. Literal types now work as expected, thanks :) |
TypeScript Version: [email protected]
Code
Expected behavior:
result
(before the narrowing) should be"FAILURE" | number
result
(after the narrowing) should benumber
Actual behavior:
result
(before the narrowing) isstring | number
result
(after the narrowing) isstring | number
The text was updated successfully, but these errors were encountered: