Repository navigation
String literal type is always widened when used as a generic type parameter #41742
Description
TypeScript Version: v4.2.0-dev.20201130
Search Terms: widening type parameter
Code:
function fn<T extends 'foo' | 'bar'>(a: T, b: T) {}
fn('foo', 'bar');Expected behavior: Argument of type '"bar"' is not assignable to parameter of type '"foo"'.. Since 'foo' and 'bar' are different types, it shouldn't be possible for the same type parameter to allow both of them.
Actual behavior: no error, since the arguments' types are widened.
This definitely seems like reasonable behavior without an explicit "extends" clause (this was brought up in #6167):
function fn<T>(a: T, b: T) {}
fn('foo', 'bar'); // I *want* this to resolve to string! only args like (10, 'foo') should breakBut this behavior is harder to justify in my first example, since I'm treating the string literal types like enum values; I want them to behave independently as their own types, not widen and act like the same type when they're being matched to the T type parameter.
It might not be so easy to tell the programmer's intention in this case, but it should be clearer here:
function fn2<T extends 'foo' | 'bar'>(a: T, b: T) {}
const foo: 'foo' = 'foo'; // I think the type annotation is redundant here because it's a const declaration
const bar: 'bar' = 'bar';
fn2(foo, bar);This still doesn't error, which tells me the type is still widening to 'foo' | 'bar' even though the arguments are very explicitly typed as 'foo' and 'bar'. The widening only seems to happen when the arguments are actually different. When I hover over the function call in VS Code (not sure what that view is called), I see two different evaluated type definitions depending on the function call:
- If I hover over
fn2(foo, foo), I getfunction fn2<"foo">(a: "foo", b: "foo"): void. - If I hover over
fn2(foo, bar), I getfunction fn2<"foo" | "bar">(a: "foo" | "bar", b: "foo" | "bar"): void.
This seems to indicate that the type system is explicitly making a widening decision when it "notices" that the literal type doesn't fit, which I don't really want it to do.
#6196 seems to be the closest anyone got to addressing the concerns raised in #6167. This seems to have been enough to close out #6167, but unfortunately it only strictifies conditional logic, not this type parameter spookiness.
Here's another (slightly closer to real-world) example, similar to what I was building when I came across this issue. In this example, it's trickier (i.e. not constant-time) to determine the second argument's type based on the first argument at runtime (where, obviously, in my first example, there's no reason to write a function that takes in the exact same string two times):
type Key = "foo" | "bar";
type Haystack<T> = Array<number | T>;
function fn<T extends Key>(a: T, b: Haystack<T>) {}
fn("foo", [1, 2, 3, 4, "foo"]);
fn("foo", [1, 2, 3, 4, "bar"]); // should error
fn("bar", [1, 2, 3, 4, "foo"]); // should error
fn("bar", [1, 2, 3, 4, "bar"]);Workarounds:
One option is to call the function with an explicitly-bound T:
function fn<T extends 'foo' | 'bar'>(a: T, b: T) {}
fn<'foo'>('foo', 'bar'); // Argument of type '"bar"' is not assignable to parameter of type '"foo"'.This also works with typeof, which (as far as I can tell) should be available in every case where the type-checker would be able to determine that there's an error anyway:
function fn<T extends 'foo' | 'bar'>(a: T, b: T) {}
const foo = 'foo';
const bar = 'bar';
fn<typeof foo>(foo, bar); // Argument of type '"bar"' is not assignable to parameter of type '"foo"'.This doesn't feel like a general-purpose solution, though, because the T parameter might not be so easily accessible:
type Key = "foo" | "bar";
type Haystack<T> = Array<number | T>;
function fn<T extends Key>(a: Haystack<T>, b: Haystack<T>) {}
fn([1, 2, 3, 4, 'foo'], [5, 6, 7, 8, 'bar']);
// fn<typeof ????>(...)
// it's trickier (but still possible) to come up with an example like this that doesn't use array literals.Here's another workaround I found, and honestly I don't understand why this works:
function fn<T extends 'foo' | 'bar', E extends T>(a: T, b: E) {}
fn('foo', 'bar'); // Argument of type '"bar"' is not assignable to parameter of type '"foo"'.The fact that introducing a second type parameter that's identical to the first one causes the type not to be widened is really surprising to me. Even if the opinion of TS is that my initial code example shouldn't result in an error, it seems like a bug that this snippet behaves differently.
Playground Link:
Related Issues:
#6167: this issue is described in the "Widening" section. this issue was closed by a few PRs that address related behavior, but which don't actually fix this particular problem.
#5300, #5517
#6196, which seems to be the closest anyone got to addressing this
Activity
jcalz commentedon Dec 1, 2020
Related issues:
#27808:
extends_oneofconstraint: Right now, generic type parameters constrained to a union type are allowed to be specified with any subset of the union, including the full union. Saying<T extends "foo" | "bar">means that the compiler should feel free to specifyTas"foo" | "bar"if it wants to. There's currently no way to say thatTcan be"foo"or"bar"but not"foo" | "bar". The proposal here would allow something like<T extends_oneof "foo" | "bar">to mean thatTshould be assignable to exactly one member of the union"foo" | "bar", which is what you want.#14829: non-inferential type parameter usage: It would be nice if you could tell the compiler that you don't want it to infer
Tfrom both theaandbparameters. Instead, you want it to inferTfrom, say, justa... and then check that such an inference works forb. Or put another way: you want theTinb: Tto be "non-inferential". If you could mark such a use likeNoInfer<T>instead of justT, then you'd get the desired behavior:There's no direct support in the language for
NoInfer<T>, but the linked issue does provide some implementations of it that work well enough in some circumstances. This is the one I tend to use:which makes the above work.
Playground link to code
SyntaxBlitz commentedon Dec 1, 2020
Thanks for the pointers!
The reason I attributed this issue to literal widening is that when I tried this with other types, I saw the behavior I was expecting:
Playground link
Do you know why this results in an error, even though
extendsbehaves as you pointed out?RyanCavanaugh commentedon Dec 3, 2020
Joe Calzaretta (@jcalz) pointed to the issues that I would have; this is very much intentional.
Just to help with search terms, zero widening whatsoever occurs in this example. The same behavior is seen when the arguments are nonwidening literal types, for example:
What's happening is that generic inference is allowed to form literal unions of a common primitive type when faced with multiple inference candidates that don't share a common supertype. This is broadly desired behavior, especially in the unconstrained case.
There's a strong consistency argument that adding an
extends Uconstraint to an existing generic function should not cause extant calls to fail if they would have succeeded with an explicitUtype argument, since that on its face makes no sense. In other wordsSyntaxBlitz commentedon Dec 4, 2020
Ryan Cavanaugh (@RyanCavanaugh) Thanks for the insight! Your logic makes sense, and indeed I was mistaking this "common primitive type" behavior for widening (because to me, it feels like a pretty similar case to where widening kicks in; where the two unioned types don't have a common supertype, they're only allowed to share that type parameter because they have the same primitive type. As far as I can tell, that's the same behavior as if the type parameter were simply interpreted as
stringin this case). I'm still not sure I understand how this example is different from the "Widening" section in #6167, though.It's also interesting to me that this behavior seems not to survive one "hop" across type parameters, like in my last 'workaround' example:
Is there a theoretical reason why this should be different from the simpler case with just one type parameter, or is it just a quirk of TypeScript's implementation of this generic inference? It does feel weird to me to do this in my own code, since it's hard (for me, at least) to understand why this 'hack' does what it does.
Thanks again for engaging.
RyanCavanaugh commentedon Dec 4, 2020
Type parameter inference occurs for all type parameters "simultaneously", rather than going through an N-round unification process that would create a backpropagation effect from
UtoT, so onceTis fixed to"a"andUis fixed to"b"there's no further changes to the inference result.SyntaxBlitz commentedon Dec 4, 2020
I see! I was thinking that the stuff inside the angled brackets somehow gets "resolved" first, not that it starts from the arguments, binds the types, and then checks against the type parameters. It makes more sense when I tihnk about it in that direction.
Thanks! Closing out the issue now :)
SirzBenjie commentedon Oct 4, 2025
Might be worth noting that you can actually achieve what you want
jcalz commentedon Oct 4, 2025
Since #41742 (comment), #56794 has been implemented so you can just do
as mentioned there without needing to simulate
NoInfervia tricks