Skip to content

String literal type is always widened when used as a generic type parameter #41742

Closed

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 break

But 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 get function fn2<"foo">(a: "foo", b: "foo"): void.
  • If I hover over fn2(foo, bar), I get function 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:

https://www.typescriptlang.org/play?ts=4.2.0-dev.20201130#code/GYVwdgxgLglg9mABMMAeAKogpgDylsAEwGdEByYOOMxAH3ICMBDAJzID4AKJgLkXQA0iBn3QBKRAG8AvgCgUnClTJCyzNmIDcQA

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

  1. jcalz commented on Dec 1, 2020

    @jcalz
    Contributor

    Related issues:

    #27808: extends_oneof constraint: 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 specify T as "foo" | "bar" if it wants to. There's currently no way to say that T can be "foo" or "bar" but not "foo" | "bar". The proposal here would allow something like <T extends_oneof "foo" | "bar"> to mean that T should 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 T from both the a and b parameters. Instead, you want it to infer T from, say, just a... and then check that such an inference works for b. Or put another way: you want the T in b: T to be "non-inferential". If you could mark such a use like NoInfer<T> instead of just T, then you'd get the desired behavior:

    function fn<T extends 'foo' | 'bar'>(a: T, b: NoInfer<T>) { }
    fn('foo', 'bar'); // error!
    // -----> ~~~~~
    // Argument of type '"bar"' is not assignable to parameter of type '"foo"'

    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:

    type NoInfer<T> = [T][T extends any ? 0 : never];

    which makes the above work.

    Playground link to code

  2. SyntaxBlitz commented on Dec 1, 2020

    @SyntaxBlitz
    Author

    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:

    function fn<T extends string | number>(a: T, b: T) { }
    
    // function fn<"foo" | "bar">(a: "foo" | "bar", b: "foo" | "bar"): void
    fn('foo', 'bar'); // no error
    
    // function fn<"foo">(a: "foo", b: "foo"): void
    fn('foo', 1); // Argument of type '1' is not assignable to parameter of type '"foo"'.
    
    // function fn<1 | 2>(a: 1 | 2, b: 1 | 2): void
    fn(1, 2); // no error

    Playground link

    Do you know why this results in an error, even though extends behaves as you pointed out?

  3. RyanCavanaugh commented on Dec 3, 2020

    @RyanCavanaugh
    Member

    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:

    function fn<T >(a: T, b: T) {}
    declare const f: "foo", b: "bar";
    fn(f, b);

    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 U constraint to an existing generic function should not cause extant calls to fail if they would have succeeded with an explicit U type argument, since that on its face makes no sense. In other words

    // If this succeeds
    function fn1<T>(a: T, b: T): void;
    fn1(x, y);
    // and this succeeds
    fn1<U>(x, y);
    // it would unjustifiable for this to fail
    function fn2<T extends U>(a: T, b: T): void;
    fn2(x, y);
  4. SyntaxBlitz commented on Dec 4, 2020

    @SyntaxBlitz
    Author

    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 string in 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:

    function fn<T extends 'a' | 'b', U extends T>(a: T, b: U) {}
    fn('a', 'b'); // fails

    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.

  5. RyanCavanaugh commented on Dec 4, 2020

    @RyanCavanaugh
    Member

    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 U to T, so once T is fixed to "a" and U is fixed to "b" there's no further changes to the inference result.

  6. SyntaxBlitz commented on Dec 4, 2020

    @SyntaxBlitz
    Author

    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 :)

  7. SirzBenjie commented on Oct 4, 2025

    @SirzBenjie

    Might be worth noting that you can actually achieve what you want

    function fn<T extends 'foo' | 'bar', K extends T = T>(a: T, b: K) {}
    fn('foo', 'bar');
  8. jcalz commented on Oct 4, 2025

    @jcalz
    Contributor

    Since #41742 (comment), #56794 has been implemented so you can just do

    function fn<T extends 'foo' | 'bar'>(a: T, b: NoInfer<T>) { }
    fn('foo', 'bar');

    as mentioned there without needing to simulate NoInfer via tricks

  9. locked as resolved and limited conversation to collaborators on Jan 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Working as IntendedThe behavior described is the intended behavior; this is not a bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions