Skip to content

Problems with comparability between string literal types #6167

Closed
@DanielRosenwasser

Description

@DanielRosenwasser

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:

let x: "foo" = "foo";
// ...
switch (x) {
    case "bar":
        console.log("wat");
}

When can x be "bar"? Without bypassing the type system, clearly never.

Equality

This is valid, which seems bad:

let x: "foo";
// ...
if (x === "bar") {
}

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:

let x = "hello" as "world";
let y = <"bar" | "baz">"foo";

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:

I think the biggest issue with the widening approach is that types are widened after type argument inference, which is an issue we have discussed before. It makes it so that any string literals that get inferred are automatically widened to string, which is a little unfortunate.

Here's the problem Jason was talking about

declare function f<T>(x: T): T;

let x = f("hello");

Here, we would always widen to string after picking a type for T. And even if we didn't, we'd still have other issues:

declare function f<T>(x: T, y: T): T;

let x = f("hello", "world");

When we try to figure out what T should be, we'd have two different types: "hello" and "world. Usually, when trying to infer T given the choice between something like number and string, 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 expressions

For the switch/case example, we could contextually type each case clause expression by the type of the switch expression:

let x: "foo" | "bar;
// ...
switch (x) {
    case "baz:": // <- error: type '"baz"' is not compatible with '"foo" | "bar"'
        console.log("wat");
}

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 the switch expression, which would try to grab the contextual type from its case clauses... etc.

So we could just make things unidirectional, which still has undesirable results

let x: "foo" | "bar;
// ...
switch ("baz") {
    case x: // <- okay: type '"foo" | "bar"' is compatible with 'string'
        console.log("wat");
}

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?

let x: "foo" | "bar";
// ...
if (x === "baz") { // <- okay: type '"foo" | "bar"' is compatible with 'string'
    console.log("wat");
}

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

let x: "foo" | "bar";
// ...
if (x === "baz") {
    console.log("wat");
}

you want "baz" to grab the type of x, 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. Since x 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

if ("hello" === "world") {
    // ...
}

because each side would just have type string, and inform the other side with the type string. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Domain: Literal TypesUnit types including string literal types, numeric literal types, Boolean literals, null, undefinedFixedA PR has been merged for this issueSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions