Skip to content
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

Generics extending unions cannot be narrowed #13995

Closed
krryan opened this issue Feb 10, 2017 · 58 comments · Fixed by #43183
Closed

Generics extending unions cannot be narrowed #13995

krryan opened this issue Feb 10, 2017 · 58 comments · Fixed by #43183
Assignees
Labels
Fix Available A PR has been opened for this issue In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@krryan
Copy link

krryan commented Feb 10, 2017

TypeScript Version: 2.2.0-dev.20170126

Code

declare function takeA(val: 'A'): void;
export function bounceAndTakeIfA<AB extends 'A' | 'B'>(value: AB): AB {
    if (value === 'A') {
        takeA(value);
        return value;
    }
    else {
        return value;
    }
}

Expected behavior:
Compiles without error.

Actual behavior:

Argument of type 'AB' is not assignable to parameter of type '"A"'.

It works correctly if I just use value: 'A' | 'B' as the argument to bounceAndTakeIfA, but since I want the return value to type-match the input, I need to use the generic (overloading could do it, but overloading is brittle and error-prone, since there is no error-checking that the overload signatures are actually correct; my team has banned them for that reason). But (I'm guessing) since AB extends 'A' | 'B', narrowing doesn't happen. In reality, I am just using extends to mean AB ⊂ 'A' | 'B', but extends can mean more than that, which (I suspect) is why TS is refusing to narrow on it.

Some alternative to extends that more specifically means ⊂ (subsets maybe?) would probably be the best solution here?

@mhegazy mhegazy added Suggestion An idea for TypeScript In Discussion Not yet reached consensus labels Feb 10, 2017
@masaeedu
Copy link
Contributor

masaeedu commented Mar 22, 2017

But extends already does mean subset. If I'm not mistaken, extends is simply TypeScript's implementation of bounded quantification.

Regarding:

but extends can mean more than that

Do you have a specific example of behavior where you'd do T extends U for some T not assignable to U?

@Nimelrian
Copy link

Nimelrian commented Jan 18, 2018

Got the same (I think) problem with a more elaborated example:

interface WithNumber {
  foo: number;
}

interface WithString {
  bar: string;
}

type MyType = WithNumber | WithString;

interface Parameter<C extends MyType = MyType> {
  container: C
} 

function isParameterWithNumberContainer(arg: Parameter): arg is Parameter<WithNumber> {
  return typeof (arg.container as WithNumber).foo === "number";
}

function test(arg: Parameter<WithNumber | WithString>) {
  if (isParameterWithNumberContainer(arg)) {
    arg.container.foo;
    return;
  }
  /*
   * Error:
   *   Property 'bar' does not exist on type 'MyType'.
   *     Property 'bar' does not exist on type 'WithNumber'.
   */
  arg.container.bar;
}

In my opinion, it is impossible that arg will be something else than a Parameter<WithString>. Typescript however still thinks that it is a Parameter<WithNumber | WithString> and thus throws an error when trying to access arg.container.bar.

@RyanCavanaugh RyanCavanaugh added Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature and removed In Discussion Not yet reached consensus labels Feb 6, 2018
@RyanCavanaugh
Copy link
Member

TL;DR from design discussion: It's somewhat obvious what the "right" thing to do is, but would require a large rework of how we treat type parameters, with concordant negative perf impacts, without a corresponding large positive impact on the actual user-facing behavior side.

If new patterns emerge that make this more frequently problematic, we can take another look.

@lingz
Copy link

lingz commented Jun 18, 2018

Again appeared in #25039

@mifopen
Copy link

mifopen commented Nov 16, 2018

How should I write such code then?

type NumberType = (() => number) | number;

function double<T extends NumberType>(
    num: T
) : T {
    if (typeof num === "number") return num * 2;
    return () => num() * 2;
}```

Error `Type 'number' is not assignable to type 'T'.`

@krryan
Copy link
Author

krryan commented Nov 16, 2018

By using return num * 2 as T; and return () => num() * 2 as T;, unfortunately. There is no better way, because while TS will narrow num it won’t narrow T for you.

@mysticatea
Copy link

mysticatea commented Jan 9, 2019

  • tsc version: 3.2.2

I have encountered this problem. It's much odd because it says "'a' does not exist" even if it's inside of if ("a" in t) block.

// Definition
type A = { a: number }
type B = { b: number }
type AorB = A | B

// Try
function f<T extends AorB>(ab: AorB, t: T): void {
    if ("a" in ab) {
        ab.a
    }
    if ("a" in t) {
        t.a // ⚠️ `a` does not exist
    }
}

Playground

@DerGernTod
Copy link

same issue, different example:

interface AllowedMapTypings {
    'str': string;
    'lon': number;
}

const obj: AllowedMapTypings = {
    'str': 'foo',
    'lon': 123
};

function foo<T extends keyof AllowedMapTypings>(key: T): AllowedMapTypings[T] {
    return obj[key];
}
let str = foo('str'); // this works fine, str is a string

function fn<T extends keyof AllowedMapTypings>(key: string, kind: T, value: AllowedMapTypings[T]) {
    if (kind === 'str') {
        console.log(value.length); // Property 'length' does not exist on type 'AllowedMapTypings[T]'.
    }
}

@rsolomon
Copy link

rsolomon commented Feb 14, 2019

This would be great for specializing Props to React components. Contrived example:

type Type = 'a' | 'b';
type AShape = { a: 'a' };
type BShape = { b: 'b' };
type Props<T extends Type> = {
  type: T,
  shape: T extends 'a' ? AShape : BShape,
};

class Test<T extends ID> extends React.Component<Props<T>> {
  render() {
    const { type, shape } = this.props;
    switch (type) {
      case 'a':
        return <>{shape.a}</>; // Ideally would narrow `shape` here, instead of `AShape | BShape`
      default:
        return <>{shape.b}</>;
    }
  }
}

<T type="a" shape={{ a: 'a' }} /> // No error in ideal case
<T type="a" shape={{ b: 'b' }} /> // error in ideal case

@OliverJAsh
Copy link
Contributor

Here is an example that demonstrates the difference in behaviour between non-generic and generic functions:

type Common = { id: number };
type A = { tag: 'A' } & Common;
type B = { tag: 'B' } & Common & { foo: number };

type MyUnion = A | B;

const fn = (value: MyUnion) => {
    value.foo; // error, good!
    if ('foo' in value) {
        value.foo; // no error, good!
    }
    if (value.tag === 'B') {
        value.foo; // no error, good!
    }
};

const fn2 = <T extends MyUnion>(value: T) => {
    value.foo; // error, good!
    if ('foo' in value) {
        value.foo; // error, bad!
    }
    if (value.tag === 'B') {
        value.foo; // error, bad!
    }
};

@zakhenry
Copy link

I think this is the same issue, this came from a real use case but I've reduced it to a toy example to better demonstrate:

interface Foo {
  stringVal: string;
  stringArrayVal: string[];
  numberArrayVal: number[];
}

type KeysWithType<T, V> = { [K in keyof T]: T[K] extends V ? K : never }[keyof T];

type ArrayPropertyOf<T> = KeysWithType<T, Array<any>>;

type ArrayTypeOfPropertyOf<T, K extends keyof T> = T[K] extends Array<infer U> ? U : never;



/// type checks

const obj: Foo = {
  stringVal: 'hello',
  stringArrayVal: ['world'],
  numberArrayVal: [1,2,3]
}

let a: ArrayTypeOfPropertyOf<Foo, 'numberArrayVal'>; // as expected, type narrowed to number.

function printArrayKeyWorking(key: ArrayPropertyOf<Foo>) {

  switch (key) {
    case 'stringArrayVal':
    case 'numberArrayVal':
    case 'stringVal': // as expected: Type '"stringVal"' is not comparable to type '"numberArrayVal" | "stringArrayVal"'.
      console.log(key);
  }    

}

function printArrayKeyNotWorking<K extends ArrayPropertyOf<Foo>>(key: K) {

  switch (key) {
    case 'stringArrayVal':
    case 'numberArrayVal':
    case 'stringVal': // no error
      console.log(key);
  }    

}

function transformChildArrayValue<K extends ArrayPropertyOf<Foo>>(key: K, value: ArrayTypeOfPropertyOf<Foo, K>) {

  switch (key) {
    case 'stringArrayVal':
      return value.repeat(2); // not expected: Type '"stringVal"' is not comparable to type '"numberArrayVal" | "stringArrayVal"'.
    case 'numberArrayVal':
      return value ** 2; // not expected: The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.
    case 'stringVal':
      return key;
  }

}

Is there actually a workaround existing for this? The real case I'm having is the same kind of problem as the last function where I am unable to correctly type the key name and value as the two parameters as the type cannot be correctly narrowed. Note this is part of a library that I want to enforce good types for the downstream developer so I'm looking for generic solution not one specific to this particular problem

Any tips for a workaround greatly appreciated!

@jhnns
Copy link

jhnns commented Jul 2, 2019

Not sure if that's the exact same problem, but I probably found a real simple case where this doesn't work as expected:

function ifNullThenUndefined<T>(value: T): T extends null ? undefined : T {
    return value === null ? undefined : value;
}

This complains at the return statement that Type 'undefined' is not assignable to type 'T extends null ? undefined : T'. See playground.

This, however, works:

type MapNullToUndefined<T> = T extends null ? undefined : T;

function ifNullThenUndefined<T>(value: T): MapNullToUndefined<T> {
    return (value === null ? undefined : value) as MapNullToUndefined<T>;
}

const a1 = ifNullThenUndefined(null); // a1 is inferred as undefined
const a2 = ifNullThenUndefined(undefined); // a2 is inferred as undefined
const a3 = ifNullThenUndefined(2); // a3 is inferred as a number

See playground.

@krryan
Copy link
Author

krryan commented Jul 2, 2019

@jhnns This is actually more closely related to another issue I’d raised, #21879. Basically, Typescript never considers the narrowing of a value, e.g. value as inplying anything about the type, e.g. T, that value has. The reason why is this:

interface A { foo: 42; }
interface B { bar: 'hello world'; }
declare function isB(value: A | B): value is B;

type Foobar<T extends A | B> = T extends A ? 'foo' : 'bar';

function demo<T extends A | B>(value: T): Foobar<T> {
    if (isB(value)) {
        return 'bar'; // errors
    }
    else {
        return 'foo'; // errors
    }
}

// here is the reason those lines error
const foo: 'foo' = demo({ foo: 42, bar: 'hello world' }); // does NOT error, but foo would be set to 'bar'

In that function call, T is A & B, which means it will pass isB and return 'bar'. But the definition of Foobar says that Foobar<A & B> should be 'foo' instead.

The “solution,” such as it is, is to use casting. Whether you do that by defining a particular type (e.g. MapNullUndefined or by just writing out as T extends null ? undefined : T, either works, but you basically are forced to tell the compiler that you have done this right, which means the compiler cannot correct you if you have not, in fact, done it right.

Which drastically limits the value of conditional types, in my opinion, since they will almost-always, necessarily, be unsafe at least within the internals of the function. And plenty of cases where A & B is never exist, which could be handled safely, but the TS team seems to be uninterested in going down that road.

@val1984
Copy link

val1984 commented Jul 23, 2019

Trying to work around the fact that generic types extending union types don't get narrowed, I came up with the following code:

type Union = "A" | "B";

function differentiate(value: "A"): Array<"A">;
function differentiate(value: "B"): Array<"B">;
function differentiate(
  value: Union
): Array<"A"> | Array<"B"> {
  switch (value) {
    case "A":
      return [value];
    case "B":
      return [value];
  }
}

const arrayA = differentiate("A"); // resulting type is Array<"A"> ✅
const arrayB = differentiate("B"); // resulting type is Array<"B"> ✅

function calling(u: Union) {
  differentiate(u); // ⛔ Argument of type 'Union' is not assignable to parameter of type '"B"'.
  return u === "A" ? differentiate(u) : differentiate(u); // Works but problematic when Union includes more litterals
}

I would have expected this code to work:

type Union = "A" | "B";

function differentiate<T extends Union>(
  value: T
): Array<T> {
  switch (value) {
    case "A":
      return [value]; // value should be of type "A"
    case "B":
      return [value]; // value should be of type "B"
  }
}

const arrayA = differentiate("A"); // resulting type should be Array<"A">
const arrayB = differentiate("B"); // resulting type should be Array<"B">

function calling(u: Union) {
  return differentiate(u); // this call should be accepted
}

Thank you in advance for any input you may have!

@ahejlsberg ahejlsberg self-assigned this Mar 10, 2021
@ahejlsberg ahejlsberg added this to the TypeScript 4.3.0 milestone Mar 10, 2021
@ahejlsberg
Copy link
Member

This issue is now fixed in #43183.

@darkbasic
Copy link

Wow, this was one of the most annoying issues for me, thanks.

@jcalz
Copy link
Contributor

jcalz commented Mar 12, 2021

Hooray!

But ugh I think I've been referencing this issue for years now without realizing that this issue is specifically about narrowing values whose types are generic, and not about the type parameters themselves. Looks like I'll have to go change a bunch of my SO answers to point to something more appropriate like #24085, #25879, #27808, and #33014.

@eugene-kim
Copy link

@ahejlsberg you've made my week with this good news! 🎉

@Sharcoux
Copy link

4 years later. I'm gonna cry I think 😃

@Krumpet
Copy link

Krumpet commented Jul 26, 2021

I realize this issue is closed, but I'm still getting this behavior. I'll try and post a minimal working example but the full usage can be seen here.

TS playground link here

I have this type, PrimitiveOrConstructor:

interface typeMap { // for mapping from strings to types
  string: string;
  number: number;
  boolean: boolean;
}

type Constructor = { new(...args: any[]): any };

type PrimitiveOrConstructor =
  | Constructor
  | keyof typeMap;

and I'm using it as the generic constraint in this function, and I have to assign the variable with type T extends PrimitiveOrConstructor in order to get narrowing:

function typeGuard<T extends PrimitiveOrConstructor>(o: unknown, className: T): boolean {

  const localPrimitiveOrConstructor: PrimitiveOrConstructor = className;

  if (typeof localPrimitiveOrConstructor === 'string') {
    return typeof o === localPrimitiveOrConstructor;
  }

  return o instanceof localPrimitiveOrConstructor;
}

If I try to skip using the type variable it doesn't work, even if I define an explicit type guard function:

function typeGuard<T extends PrimitiveOrConstructor>(o: unknown, className: T): o is GuardedType<T> {

  if (isPrimitiveName(className)) {
    return typeof o === className; // className inferred as T & keyof typeMap
  }

  return o instanceof className; // error here
}


function isPrimitiveName(className: PrimitiveOrConstructor): className is keyof typeMap {
    return (typeof className === 'string')
}

@sevarubbo
Copy link

Another simple example of a problem. With generic extending a union type, automatic type guards don't work.
See code in the playground.

function fn1<T extends "a" | "b" | "c">(t: T) {
  if (t === "a") {
    if (t === "b") {
      // Unreachable, error not highlited
    }
  }
}

function fn2(t: "a" | "b" | "c") {
  if (t === "a") {
    if (t === "b") {
      // Unreachable, error highlited
    }
  }
}

@Cauen
Copy link

Cauen commented Mar 13, 2022

Another simple example of a problem. With generic extending a union type, automatic type guards don't work. See code in the playground.

function fn1<T extends "a" | "b" | "c">(t: T) {
  if (t === "a") {
    if (t === "b") {
      // Unreachable, error not highlited
    }
  }
}

function fn2(t: "a" | "b" | "c") {
  if (t === "a") {
    if (t === "b") {
      // Unreachable, error highlited
    }
  }
}

Another example. Code

function add(a: string | number, b: number) {
  if (typeof a === "string") {
    return `${a}${b}`
  }
  return a + b // ✔
}

function add2<T extends string | number>(a: T, b: number) {
  if (typeof a === "string") {
    return `${a}${b}`
  }
  return a + b // ⚠ Operator '+' cannot be applied to types 'T' and 'number'
}

@leonchabbey
Copy link

Same issue here but user defined type: Code

// Type definitions

enum CarType {
    sedan = 'sedan',
    suv = 'suv',
    truck = 'truck'
}

type CarSedan = {
    type: CarType.sedan;
    randomKey1?: string;
}

type CarSUV = {
    type: CarType.suv;
    randomKey2?: number;
}

type CarTruck = {
    type: CarType.truck;
    randomKey3?: string;
}

//. Type guard using the key "type" to differentiate the union types

const isCarTruck = <
    Truck extends CarTruck,
    Others extends CarSedan | CarSUV,
    All extends Truck | Others
>(
    car: All
): car is Exclude<All, Others> =>
    (car as CarTruck)?.type === CarType.truck;


// Subset
type Union = CarTruck | CarSedan | CarSUV


// Example 1: In a normal usage its working

const func1 = (car: Union) => {
    isCarTruck(car)
        ?
        car // CarTruck
        :
        car // CarSedan | CarSUV
}

// Example 2: With generic extends it doesnt work

const func = <T extends Union>(car: T) => {
    isCarTruck(car)
        ?
        car // "Exclude<T, CarSedan | CarSUV>"" ----> CarTruck
        :
        car; // "T extends Union" ----> ?????
}

@alekangelov
Copy link

alekangelov commented Jul 7, 2022

Can confirm generic union type guards aren't working

Playground link

type Types = "1" | "2" | "3"

type XYZ<T extends Types> = {
  type: T;
  x: T extends "1"
    ? number
    : T extends "2"
    ? string
    : T extends "3"
    ? boolean
    : never;
};

function y<T extends Types>(x: XYZ<T>) {
  if(x.type === '1') {
    x.x + 1 // fails because it's number or string or boolean
  }
}

@douglasg14b
Copy link

From the look of this thread, the issue is NOT really resolved at this point, and we cannot narrow dependent types?

@RyanCavanaugh
Copy link
Member

A very high proportion of comments here are misunderstanding a fundamental aspect of how union-constrained generics work.

It is not safe to write code like this

function f<T extends number | string>(x: T, y: T): string {
    if (typeof x === "string") {
        return y; // <- No!
    }
    return "ok";
}

because a caller can legally write

f<number | string>("hello", 42);

Narrowing one variable of a type parameter's type does not give you sound information about another variable of the same type parameter's type.

@douglasg14b
Copy link

douglasg14b commented Jul 12, 2022

@RyanCavanaugh Gotcha, thanks for that explanation! I can see how that's unsafe.

What about this example?

const Actions = {
	Action1: 'Action1',
	Action2: 'Action2',
} as const;
type Action = ValueOf<typeof Actions>;

// Some dynamic parameters
type ParamConstraints = {
	[Actions.Action1]: { name: string, age: number },
	[Actions.Action2]: { company: string }
};

function doSomething<TAction extends Action>(action: TAction, param: ParamConstraints[TAction]) {
	if(action === 'Action1') {
		param.name // Property 'name' does not exist on type '{ name: string; age: number; } | { company: string; }'.
	}
	
	if(action === 'Action2') {
		param.company // same error
	}

	switch(action) {
		case 'Action1':
			param.name // same error
			break;
		case 'Action2':
			param.company // same error
			break;
	}
}

// Expected error about the wrong structure for the 2nd argument
doSomething('Action1', { company: 'test' })

This seems to enforce the contract for callers (ie. I must call doSomething() with the correct arguments as dictated by ParamConstraints) . Shouldn't this be safe for narrowing inside of doSomething()?

Or is this something entirely different?

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Jul 12, 2022

This seems to enforce the contract for callers

No

// Sound upcast
const p = 'Action1' as 'Action1' | 'Action2';
// Correct call causes unsoundness in function body
doSomething(p, { company: 'test' });

@douglasg14b
Copy link

douglasg14b commented Jul 12, 2022

Thank you for the response!

To me that looks like self-sabotage by intentionally casting to something not intended? I'm not sure if I would consider that a case that needs defending against for that example, but I'm very likely wrong 😅

I guess this spawns 2 questions for you:

  1. Is this sort of narrowing even possible/supported in TS?
  2. If it is. Is there further constraint that I need to make this safe? Or how can this be done? (Does it have a name?)

@coder-mike
Copy link

coder-mike commented Dec 19, 2022

This is the work-around I'm using. The key function in the following is the asUnion function, and its example use in discriminateExample. Explanation below.

type Types = {
  'Num': number
  'Str': string
}
type Names = keyof Types // 'Num' | 'Str'
type Inner<T extends Names> = Types[T]

interface Box<T extends Names> {
  type: T
  value: Inner<T>
}

// Example of generic operation on Box
function unboxExample<T extends Names>(box: Box<T>): Inner<T> {
  // `box.value` is correctly inferred with type `Inner<T>`
  return box.value
}

function asUnion<T extends Names>(box: Box<T>): T extends any ? Box<T> : never {
  return box as any
}

function discriminateExample<T extends Names>(box: Box<T>) {
  const { type, value } = asUnion(box);
  switch (type) {
    case 'Num': return value + 1; // `value` narrowed to `number`
    case 'Str': return value + ' text'; // `value` narrowed to `string`
  }
}

For my use case, I have a generic Box<T> container, where T is a type name (Num or Str in the example, to keep things simple).

I have some operations on boxes that I want to implement generically -- unboxExample is an example. And I have some operations on boxes where I want to discriminate on the different types of boxes and implement each case individually -- discriminateExample is an example. The issue, as raised in this thread, is that it's hard to do both kinds of operations on the same types: if Box<T> is a union then generic operations require casting, and if Box<T> is not a union then you can't discriminate on the individual possibilities.

The solution here is the asUnion function, which internally does a cast from the generic form to the union form, but at least the cast is only in one place. The return type of asUnion uses a conditional type to expand out the discriminated union based on all the possible type names T.

For example, if the argument of asUnion is Box<'Num' | 'Str'> then the return type is Box<'Num'> | Box<'Str'>, which is then compatible with TypeScript's narrowing logic.

image

@thierry-stul
Copy link

The fix seems very incomplete to me. A simple re-assignment of the narrowed variable will break it:

type Type = 'a' | 'b';

let testVariable: 'a'

function testTypeNarrowing<T extends 'a' | 'b'>(t: T): 'a' | null {
  if (t === 'a') {
    // this assignment works, making you believe t has been correctly narrowed
    testVariable = t
    // t has not actually been narrowed here
    const x = t
    return x  //compilation error, but returning t itself woudl work!
  }
  return null
}

This of course causes all sorts of related problems. In light of this, shouldn't this issue be reopened for having a main source of reference on these kind of issues?

@Woodz
Copy link

Woodz commented Jun 25, 2024

This also doesn't work with narrowing conditional types (tested with both enums and unions in TS 5.5):

function getConcreteEnumImpl<TEnum extends MyEnum>(myEnum: TEnum): SpecializedTypeOfEnum<TEnum> {
  switch (myEnum) {
    case MyEnum.bar: {
      return new BarEnumSpecialization(); <-- Typing error because TS does not narrow `TEnum` to `bar`
    }
    case MyEnum.foo: {
      return new FooEnumSpecialization();
    }
    default: throw new Error();
  }
}

Full TS playground link

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Fix Available A PR has been opened for this issue In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

Successfully merging a pull request may close this issue.