-
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
Problems with comparability between string literal types #6167
Comments
/cc @Aleksey-Bykov |
For the part where you quoted me, this is not quite what I meant. In the example you gave: declare function f<T>(x: T, y: T): T;
let x = f("hello", "world"); The widening approach would be a problem here, but not because of the widening itself. Rather, because the string literals are initially typed as string literal types, the type argument inference will fail. Neither What I meant was in an example like this: var hello: "hello";
declare function f<T>(param: T): T;
f(hello); You'd want this to infer I'm pretty sure widening is not appropriate here. Widening is what you do when some kind of type can arise in an expression context, but you don't want that type to land on any named entity (like a variable). You clearly want to allow variables to have string literal types, so to me widening is automatically ruled out. One other argument. In the statement var b: "hello" = "hello";
var a = b; // a would have type string if you widen So the two variables would not have the same type. |
I think that the solution for all 3 problems should be one big "NO". This is a type script after all, don't use literal types if they scare you. "NO" doesn't mean that there is no way around for someone who is looking for troubles:
So I wish that the right way of doing things was a default, and the wrong way was a harder-to-come-with option. |
I think, Nondeterministic intersection type for string literal value
|
@JsonFreeman sorry, my mistake. There are two issues there. I'll amend the original content. |
In the example declare function f<T>(x: T, y: T): T;
let x = f("hello", "world"); You claim that it would be more desirable to infer |
Why does the type assertion problem happen? I thought in a type assertion, the operand is contextually typed by the asserted type. Doesn't that take care of it? |
Here is why something like Prior to string literal types, we would perform to/from assignability checking on all types to see if they are compatible in the three listed locations. However, that couldn't capture the following behavior: var x: "foo" | boolean;
// ...
var y = x as string; // error: 'string' is not assignable to 'boolean', and vice versa The comparable relationship was supposed to fix this, but instead we just simply settled for a different fix where a string-like type, or a union containing a string-like type, is compatible with any other string-like type, or union containing a string-like type. |
I see, that makes sense. But I also don't think it is specific to string literal types. In logical terms, what you really want to check is not assignability, but consistency. For all three of the cases you pointed out, whether or not they include string literal types, what matters is whether the types involved have any values in common. In other words, there should be an error if the intersection of the sets corresponding to the types is empty. This is not adequately captured by assignability, regardless of contextual typing or any other typing mechanism. I think this needs to be a new type relation (consistency). If that is deemed too complex, then I think the only other realistic alternative is to just allow all three cases. Anything else is smoke and mirrors, it's not really addressing the issue at its core. I also do not agree with the notion of any string-like type being assignable to all other string-like types. It is nice for type assertions, but too permissive for other scenarios involving assignability. |
I realize now what is different about string literal types versus object types for example. For object types, consistency is theoretically the goal, but it holds trivially in almost all cases. Pretty much all pairs of objects are consistent if you can take their intersection. So this is not very interesting, and so consistency is not enough to imply that the types are related in some meaningful way. For string literals on the other hand, because the types map to specific values, consistency is not trivial, and it makes more sense to rely on it for type assertions and equality checks. So I think the answer is a relation that somehow covers consistency, but checks something stronger in the cases where consistency is trivial (like object types). |
Spoken offline with @ahejlsberg and @RyanCavanaugh. @ahejlsberg brought up the idea of a specialized "top-level" widening. Basically, here's the semantics that would be involved.
Additionally, another change would be to union multiple string literal return types rather than complaining about no best common type. |
Reactions:
|
|
Here's a few motivating scenarios. const a = "foo";
const b = a; Both namespace Kind {
export const Foo = "Foo";
export const Bar = "Bar";
}
let kind = Kind.Foo; The general consensus is that interface Option {
kind: "string" | "number" | Map<number>;
}
let option: Option;
let kind = option.kind; Ideally, const supportedTsExtensions = [".ts", ".tsx", ".d.ts"];
const supportedJsExtensions = [".js", ".jsx"];
const allSupportedExtensions = supportedTsExtensions.concat(supportedJsExtensions); Ideally, this should succeed. This is in contrast to potential behavior in which |
So widening occurs unless both
Does this include other effects of widening (I'm thinking of null and undefined, fresh object literals, etc)? const c = null;
var array = ["hello", c]; // is this any[] or string[]? If you suppress widening for top level initializers, what about nested parts of the initializer if the result is destructured? I'm trying to come up with an example to demonstrate this, but it doesn't seem like a very useful pattern: var kindAndVal: {
kind: "kindA";
val: any;
};
const { kind } = kindAndVal; // Does the const kind have type "kindA"? Overall, I'm still of the opinion that widening should be used in order to prevent certain types from "landing on" a variable. That was the original goal of widening. It seems like this is stretching the goal of widening in an unnatural way, particularly because string literal types can be written in a type annotation. Previously, every type affected by widening could only be inferred. This seemed like a crucial property of widening. Why the sudden switch in the mentality of widening? |
@JsonFreeman we came up with a few rules yesterday:
|
Ok, and I presume that means a string literal type annotation introduces a non-fresh type, correct? Though my above questions about other effects of widening, and destructuring still stand. And I am still curious about the philosophy of widening. To confirm, is it not important that types affected by widening can only be inferred, never denoted directly? |
String literals currently only acquire a string literal type if they are contextually typed by another string literal or a union of string literals. They are then comparable and assertable to any string-like type (i.e.
string
, another string literal type, and any union with a string literal type inside of it).By and large, this gives us the behavior you want; however, there are some problems with this approach.
Problems
switch
/case
This is valid, which seems bad:
When can
x
be"bar"
? Without bypassing the type system, clearly never.Equality
This is valid, which seems bad:
This is similar to the
switch
statement example. When will this condition be true? Again, without fooling the type system, it can't be.Type assertions
These are both valid, which seems bad:
This is where we allow you to lie to the type system. This is probably way too lenient, so I'd say that this is bad.
Solutions?
Most of the solutions here would require us to tighten our rules regarding allowing these operations between string-like entities. Essentially, we would need to capture some of the same behavior originally proposed in the pull request about a new "comparable" type relationship (#5517).
Widening
Why don't we have every string literal start out with a string literal type and widen to
string
when needed? We could certainly do that, but the question is when does a string literal type need to be widened?To quote @JsonFreeman on #5300 (comment), widening as it would stand today would be a problem for type argument inference:
Here's the problem Jason was talking about
Here, we would always widen to
string
after picking a type forT
. And even if we didn't, we'd still have other issues:When we try to figure out what
T
should be, we'd have two different types:"hello"
and"world
. Usually, when trying to inferT
given the choice between something likenumber
andstring
, we'll error. We could change this behavior, and while it might be more desirable to infer"hello" | "world"
here, it would certainly be less consistent.Contextually type
case
expressionsFor the
switch
/case
example, we could contextually type eachcase
clause expression by the type of theswitch
expression:But what about the other way around? We can't contextually type in both directions, because that would create a circularity. A
case
clause expression would try to grab a contextual type from theswitch
expression, which would try to grab the contextual type from itscase
clauses... etc.So we could just make things unidirectional, which still has undesirable results
And admittedly, that code is less likely to be written, but really, what is fundamentally different about the first
switch (x)
example and the following?Clearly you want an error here too, so it would be kind of weird to specially treat
switch
statements, but not equality comparisons.Come up with a some new flow of information, like contextual typing
For equality checks, it'd be nice to have something that can flow both ways. For instance, in this example
you want
"baz"
to grab the type ofx
, independent of this type relation, and apply the same sort of information towards creating a string literal type as you would given a contextual type. Sincex
would have the type of"foo" | "bar"
, which is a union containing string literal types,"baz"
would acquire its string literal type for the purpose of this check.You wouldn't end up catching the following
because each side would just have type
string
, and inform the other side with the typestring
. But you can already do that today, and this is a fairly silly case anyway, so it probably doesn't matter.The biggest question is how we plan to implement this. It would certainly need a proposal.
Come up with some ad hoc checks
We could come up with some weak checks in these positions to figure out if the user is likely making an error. This would need a proposal.
The text was updated successfully, but these errors were encountered: