TypeScript
TypeScript copied to clipboard
Intrinsic types remove widening on literal types
Bug Report
🔎 Search Terms
intrinsic, uppercase, lowercase, widening
🕗 Version & Regression Information
Occurs in Nightly (5.0.0-dev.20221118) and every version I tried going back to 4.1.5
⏯ Playground Link
Playground link with relevant code
💻 Code
declare const x: boolean;
declare function capitalize<T extends string>(it: T): Capitalize<T>;
// example 1
let greeting1 = "hello";
if(x) {
greeting1 = "goodbye"; // allowed
}
// example 2
let greeting2 = capitalize("hello");
if(x) {
greeting2 = capitalize("goodbye"); // type error
}
This example is adapted from @RyanCavanaugh's example here.
🙁 Actual behavior
Reassigning a new string to greeting1 succeeds, but reassigning one to greeting2 fails.
🙂 Expected behavior
The widening behavior for standard literal types (like "hello") should not be different from the behavior of the type produced by Capitalize<"hello">; if the widening behavior were the same, greeting2 would also have its type inferred as string, and would accept a new string being assigned to it.
Having inconsistent widening like this seems unintuitive and possibly unintentional. Concretely, it also makes it impossible to use an intrinsically-typed value in a let statement (as shown), which creates a similar problem in @RyanCavanaugh's original example in #44268.
The widening that happens here is completely different though. The wrapping capitalize call changes everything here. You always~ get an inferred type from the given expression based on the surrounding context.
When assigning a string to a let variable the type of this variable becomes string when you pass it through a function that can infer a different type based on its arguments then u will get the type returned by that function. It matches your expectations here - otherwise, you wouldn't use a Capitalize<T> there as Capitalize<string> is roughly equivalent to just string. So clearly you have wanted to get a literal type there.
From that moment the type of that variable is fixed - it won't widen on the following assignments. It's basically the same case as this one:
declare const x: boolean;
let greeting1 = "hello";
if (x) {
greeting1 = 42; // Type 'number' is not assignable to type 'string'.(2322)
}
This one is actually "fixable" by using "auto" types like here (by not annotating the greeting1's type):
declare const x: boolean;
let greeting1
greeting1 = "hello";
if (x) {
greeting1 = 42;
}
declare function acceptStr(a: string): void
declare const x: boolean;
let greeting1
greeting1 = "hello";
if (x) {
acceptStr(greeting1) // ok, the auto type is `string`
greeting1 = 42; // ok, `number` gets pushed locally to the automatically created union~
acceptStr(greeting1) // not ok, the auto type is `string | number`: Argument of type 'number' is not assignable to parameter of type 'string'.(2345)
}
So, in a way, you could leverage the auto types in your example: TS playground. This would look quite weird though, so I would advise you to just provide an explicit generic argument like here.
Perhaps what you'd like to request here is to annotate a variable as "auto" - so you wouldn't have to add a generic type param and you wouldn't have to split the initial variable declaration and the initial value assignment
@Andarist If I’m reading between the lines properly, what the OP actually wants is for const foo = "foo" to remain a “widening literal” type, and for Capitalize<typeof foo> to then also be a widening literal type. Right now the widening flag is lost upon passing it through Capitalize (or possibly even earlier, just from being passed through a generic), causing the return value of capitalize to not be widened.
declare function capitalize<T extends string>(x: T): Capitalize<T>;
const foo1: "fooey" = "fooey"; // non-widening
const foo2 = "fooey"; // widening
let bar = foo1; // "fooey"
let baz = foo2; // string
let cap1 = capitalize(foo1); // "Fooey"
let cap2 = capitalize(foo2); // also "Fooey"!
@fatcerberus thanks for this explanation! I had no idea about widening being a flag. The original code used to motivate this didn't make a lot of sense to me - not saying that it was bad, just that I wasn't able to infer the exact intention based on it. Your example clears this up for me since it shows how both seemingly same types behave differently here.
Yeah, it’s a bit subtle and somewhat confusing. It works this way so that const x = "foo" can act as a literal type in contexts that need one without preventing { prop: x } from widening to { prop: string }.
@fatcerberus Thanks for explaining exactly what I was getting at and adding a better example.
Your earlier comment pointed me to a critical nuance too: the widening flag is actually getting lost when the type is inferred for the generic, even before Capitalize comes into play. Ie, this example also fails:
declare function stringId<T extends string>(x: T): T;
const foo = "fooey"; // widening
let id1 = foo;
let id2 = stringId(foo); // return type is non-widening
id1 = "hello" // ok
id2 = "hello" // type error
I think my OP is correct that Capitalize et al also discard the widening flag, but it’s hard to test, because it looks like even typeof discards the flag!
const foo = "fooey"; // widening
let id1 = foo; // widening
let id2 = foo as typeof foo; // non-widening
id1 = "hello" // ok
id2 = "hello" // type error
I don’t see any world where the above is sane behavior; I think almost everyone would expect that foo as typeof foo is a no-op.
More generally, the fact that the widening flag is being lost so early/in so many places makes me wonder if there isn’t a more fundamental issue here.
In particular, my understanding of the use case for widening is (as you laid out) to enable inferring a non-literal type when a literal type is assigned to a mutable slot (whether that’s a let variable, a non-readonly object property, etc). So, now I’m wondering: is having a widening flag on the literal type the right approach to achieve that?
Take your example where the flag is not lost (ie, the type is marked non-widening from the beginning, and that’s preserved):
const foo: "foo" = "foo"; // non-widening
let bar = foo; // "foo"
bar = "bar" // type error
This behavior seems bad, because the only reason one would assign foo to a let variable is to be able to reassign the variable later, and yet that’s impossible.
So I’m curious if @RyanCavanaugh or @ahejlsberg can comment on widening was stored as a flag on literal types in the first place. I’m sure there was a reason (maybe around performance or compiler design limitations precluding other implementations?), but it seems weird. Ie, it seems like this flag is a pretty brittle proxy for what we actually care about, which is the mutability of the slot that the literal type is being placed in.
In particular, my understanding of the use case for widening is (as you laid out) to enable inferring a non-literal type when a literal type is assigned to a mutable slot
It’s a bit more nuanced than that: when something is explicitly typed as a literal or acted on with as const, you generally don’t want that to be widened, ever. So the distinction between widening and non-widening literals does still need to exist.
for example it’s common for people to write { prop: "foo" as const, quux: 42 } to get { prop: "foo", quux: number }
It’s a bit more nuanced than that
Yeah, I just discovered #11126 — though I’m still reading through it! — so I see that at least some of this behavior is intentional. I’m still not sure it makes a ton of sense (I’ll respond to your specific example below), but I definitely need to read through all the other motivating examples in #10898.
for example it’s common for people to write { prop: "foo" as const, quux: 42 } to get { prop: "foo", quux: number }
Yeah, I do that sort of thing all the time. But it seems to me now that that use of as const is really just covering up the fact that there’s no syntax like:
const x = { readonly prop: "foo", quux: 42 }
That would make prop an immutable location, which also leaves the literal unwidened and seems like the real authorial intent. (There’s probably no reason to have "foo" be the only legal type in prop and yet have prop be mutable.)
From my very quick skim a #10898, it seems like the real issue here may have to do with unions containing literals. Ie, you definitely don’t want "a" | "b" to always widen to string if you put it in a mutable variable, which is what would happen under my naive approach of going back to the pre-11126 behavior.
Still, I wonder if there’s a better rule here than the one landed on in #11126. At the very least, it seems like there still might be some bugs here with the widening flag getting lost more often than it should.
In particular, this behavior that @ahejlsberg said should happen for c3 in the linked example doesn’t seem to be happening — or at least not when the generic has a constraint of string
EDIT: I see that this logic was intentionally introduced in https://github.com/microsoft/TypeScript/pull/24310. The heuristic there seems to cause its own problems, though. The stated rationale in #24310 was:
a primitive type in the constraint indicates that the intended target is a subtype of the primitive type, i.e. a literal type.
But I don’t think that really makes sense?
-
A function like
declare capitalize<T extends string>(it: T): Capitalize<T>isn’t necessarily trying to preclude someone from callingcapitalizewith an argument that has a non-literal type (iestring); it’s just not gonna give back as precise a return type. -
Even if we assume that the return type is intended to be a literal type, that doesn’t seem to mean it shouldn’t widen in a mutable location (my original example in the OP shows why arguably it should).
~~It seems like a possibly-better fix to #23649 would’ve been a rule that determines widening based on whether the result type is a union of literal types; if so, don’t widen, but widen if there’s only one literal type.~~
FWIW the widening behavior also plays into function returns - changing bare literals to never widen would be a breaking change in that function getStr() { return "foo" } would now return a literal type instead of string.
Yeah, that would definitely need to be accounted for in and not change in any update of the widening rules
@RyanCavanaugh @ahejlsberg Any thoughts here? I think this issue could benefit from some expert eyes who understand TS internals better than I do
Sorry, just catching up after vacation/sickness.
There's a lot to read here but maybe a simple summary: AFAICT, 100% of the behavior described here so far was intentionally designed when looking at its effects in real code examples that "seem" like they should/should not have type errors. There aren't long-standing years-missed bugs around widening/nonwidening literal types -- edge cases, yes? But it's not fundamentally broken in some obviously-fixable way; the behavior in #24310 has been present for four years now, without substantial complaint as far as I'm aware.
The Gordian Knot you have to cut here is that people want this example to have zero type errors:
const x = "hello";
fn(x);
let y = x;
y = "world";
declare function fn(x: "hello"): void;
If you have some concrete idea of what you want to change and why I'm happy to hear it. PRs welcomed too - it's easy for us now to run a large validation suite to discover what would be broken.
Even if we assume that the return type is intended to be a literal type, that doesn’t seem to mean it shouldn’t widen in a mutable location (my original example in the OP shows why arguably it should).
In all places where an inference gets both co- and contra-variant observation that's a trade-off to be considered, but I don't think the example in the OP is particularly compelling since ultimately one person or another will need to write a type annotation somewhere, and in general we'd prefer that people be forced to write a type annotation that widens a type than narrows one, if forced to chose. This change will absolutely break people, and the break needs to be explicable in terms of upside provided in return.
@RyanCavanaugh My reading of the issue is that the “Gordian Knot” example you posted should still work as it does today; this is essentially a feature request so that passing the “widening” literal through a generic id() would retain both the literal type AND the widening behavior. Currently it becomes a non-widening literal in that case.
Hey @RyanCavanaugh, sorry to hear you were sick. Welcome back, and I hope you were able to have a nice thanksgiving.
There aren't long-standing years-missed bugs around widening/nonwidening literal types -- edge cases, yes? But it's not fundamentally broken in some obviously-fixable way; the behavior in https://github.com/microsoft/TypeScript/pull/24310 has been present for four years now, without substantial complaint as far as I'm aware.
I agree: there's clearly not a huge amount of pain caused by the current system. My hope would still be that at least some of these edge cases are avoidable, without complicating the underlying rules, but I confess that I can't think of concrete proposal right now that would definitely improve things enough to justify the breakage.
I think @fatcerberus is right that undoing #24310 would address the OP and wouldn't produce problems on your "Gordian Knot" example — but it would reintroduce the issue that #24310 was used to solve, and that's where I'm stumped.
In the example from #23649:
export function keys<K extends string, V>(obj: Record<K, V>): K[] {
return Object.keys(obj) as K[]
}
declare const langCodeSet: Record<"fr" | "en" | "es" | "it" | "nl", true | undefined>;
const langCodes = keys(langCodeSet);
langCodes is supposed to be an array of string literals, rather than string[].
Had #24310 never been introduced, I think I would've tried to accomplish that in some other way, rather than cueing off of the extends string on the generic. E.g., maybe #23649 could've been solved with a rule saying that the literal type inferred for K shouldn't be widening because K is used in an object key position (in the Record mapped type). I'm not saying that would've been an ideal (or even workable) rule, but just that maybe there were potential alternatives before #24310 was added.
However, given that #24310 has been introduced, I bet a bunch of people now are relying on getting literal types in all sorts of places where they were previously being widened, so rolling back #24310 now would probably be too disruptive.
I looked in my own code and found a function like this:
function gqlSuccessResult<T extends object, U extends string>(result: T, name: U) {
return {
__typename: name,
...result
};
}
In that example, I'm relying on #24310 to give me a literal type for the inferred type of the __typename key, and I can't think of an alternate rule (that doesn't require any code changes) that would do that.
Had #24310 not been introduced, I might've proposed addressing that use case like this:
function gqlSuccessResult<T extends object, U extends string>(result: T, name: U) {
return { __typename: name as const, ...result };
}
In the above snippet, I'm extending the set of places where as const can legally appear; instead of being allowed only on literals, I'm saying it'd be allowed on name because name's type is constrained such that it might have a literal type, and the as const there would mean "don't make the literal type widen".
But, again, now that people are relying on #24310, I doubt the breakage would be justified.
So, idk, maybe I'll keep noodling on this and, for now, I'll just open a separate issue for the one concrete bug that I think this thread identified, which is that foo as typeof foo should always be a no-op (imo).