TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Generic type gets widened in an unexpected way

Open Harpush opened this issue 2 years ago • 7 comments

Bug Report

🔎 Search Terms

widen

🕗 Version & Regression Information

4.9.4

⏯ Playground Link

Playground link with relevant code

💻 Code

enum One {
  A = 'a',
  B = 'b',
  C = 'c'
}

const isOneSomethingMap = {
  [One.A]: true,
  [One.B]: false,
  [One.C]: true
} as const satisfies Record<One, boolean>;

type BooleanMapToUnion<T extends Record<string, boolean>> = {
  [P in keyof T]: T[P] extends true ? P : never;
}[keyof T];

type SomethingOne = BooleanMapToUnion<typeof isOneSomethingMap>;

const a = <T>(value: T) => value;
const b = <T>(fn: (value: T) => T, v: T): T => fn(v);

const v: SomethingOne = One.A as SomethingOne;
const v2: One.A | One.C = One.A as One.A | One.C;

const r1 = b(a, v); // One and not SomethingOne - not expected
const r2 = a(v); // SomethingOne as expected
const r3 = b(a, v2); // Union as expected

const r4 = b(a, One.A as One.A | One.C); // Works
const r5 = b(a, One.A as SomethingOne); // Still doesn't work...

🙁 Actual behavior

The type gets widened when using BooleanMapToUnion... I guess it is the culprit.

🙂 Expected behavior

Both union and using BooleanMapToUnion should work equally.

Harpush avatar Jan 15 '23 17:01 Harpush

In ts 5.0 you can just write const b = <const T>(fn: (value: T) => T, v: T): T => fn(v); (Playground).

See https://github.com/microsoft/TypeScript/pull/51865 for details.

whzx5byb avatar Jan 20 '23 18:01 whzx5byb

@whzx5byb It's indeed better but:

  1. You lose the original type SomethingOne and get union instead.
  2. The original problem came from rxjs types which I have zero control over.

Adding another weird case and easier one (again works for union and not BooleanMapToUnion):

const e = <T>(value: T): {a: T} => ({a: value});
const g = e(v); // Gets widened
const e2 = <T>(value: T): T => value;
const g2 = e2(v); // Works

Harpush avatar Jan 20 '23 22:01 Harpush

The widening behavior is intended, see https://github.com/microsoft/TypeScript/pull/10676.

And if you want to preserve the type alias you can just add & {} just like type SomethingOne = BooleanMapToUnion<typeof isOneSomethingMap> & {}, according to https://github.com/microsoft/TypeScript/issues/31940#issuecomment-841712377.

whzx5byb avatar Jan 21 '23 00:01 whzx5byb

With all respect to the new const T feature I think using it here just masks the bug. There is no reason using type SomethingOne = One.A | One.C will behave correctly while BoooleanMapToUnion won't. const T will force specific types and probably libraries like rxjs won't change everything to it thus the problem remains.

Considering & {} - Seems like that solves it even without const T... Thats certainly weird though.

Harpush avatar Jan 21 '23 07:01 Harpush

I’m pretty sure that BooleanMapToUnion<typeof isOneSomethingMap> doesn’t actually produce One.A | One.C. Setting a property to never in the mapped type doesn’t remove it. You need to use an as clause to rename the key to never to remove it.

fatcerberus avatar Jan 22 '23 15:01 fatcerberus

I’m pretty sure that BooleanMapToUnion<typeof isOneSomethingMap> doesn’t actually produce One.A | One.C. Setting a property to never in the mapped type doesn’t remove it. You need to use an as clause to rename the key to never to remove it.

That's exactly what I am doing though...

Harpush avatar Jan 23 '23 14:01 Harpush

I'd agree something weird appears to be happening here. A demonstration is to slightly tweak these definitions

const b = <T>(fn: null | ((value: T) => T), v: T): T => fn!(v);
const r1 = b(null, v); // SomethingOne, as expected

The call of b(a, v) where a is the identity function shouldn't have changed the inference to One here; I don't see a good reason for that to have happened.

RyanCavanaugh avatar Jan 23 '23 18:01 RyanCavanaugh