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

KnownKeys<T> breaking change in 4.3.1-rc #44143

Open
jakearchibald opened this issue May 18, 2021 · 9 comments · May be fixed by #48648
Open

KnownKeys<T> breaking change in 4.3.1-rc #44143

jakearchibald opened this issue May 18, 2021 · 9 comments · May be fixed by #48648
Assignees
Labels
Bug A bug in TypeScript Domain: Conditional Types The issue relates to conditional types Fix Available A PR has been opened for this issue Rescheduled This issue was previously scheduled to an earlier milestone

Comments

@jakearchibald
Copy link

jakearchibald commented May 18, 2021

Bug Report

🔎 Search Terms

KnownKeys, never, getting known interface keys

🕗 Version & Regression Information

  • This changed between versions 4.3.0-beta and 4.3.1-rc

⏯ Playground Link

💻 Code

type KnownKeys<T> = {
  [K in keyof T]: string extends K ? never : number extends K ? never : K
} extends { [_ in keyof T]: infer U } ? U : never;


interface HasStringKeys {
  [s: string]: any;
}

interface ThingWithKeys extends HasStringKeys {
  foo: unknown;
  bar: unknown;
}

const demo: KnownKeys<ThingWithKeys> = 'foo';

🙁 Actual behavior

demo has type never.

🙂 Expected behavior

demo has type 'foo' | 'bar'.

Other info

I'm not sure where I got KnownKeys from, but it appears on https://stackoverflow.com/a/51956054/123395.

This issue impacted https://www.npmjs.com/package/idb, but I've already worked around the issue by using the other method on that StackOverflow post. jakearchibald/idb@e3c76a5

@jakearchibald
Copy link
Author

jakearchibald commented May 18, 2021

For others that run into this issue, the workaround was to replace KnownKeys with:

type RemoveIndex<T> = {
  [P in keyof T as string extends P
    ? never
    : number extends P
    ? never
    : P]: T[P];
};

type KnownKeys<T> = keyof RemoveIndex<T>;

Edit: I switched to @DanielRosenwasser's version in #44143 (comment), since it's compatible with more TS versions. The workaround above caused compat issues jakearchibald/idb#223

@whzx5byb
Copy link

Another workaround:

type KnownKeys<T> = {
  [K in keyof T]: string extends K ? never 
                : number extends K ? never 
                : K
} extends infer R 
  ? R extends{[_ in keyof T]: infer U} 
    ? U 
    : never
  : never
;

@DanielRosenwasser
Copy link
Member

Yeah, looks like there's definitely a change here, and I'm not sure exactly what is the root cause.

Here's what I came up with as a workaround.

type KeyToKeyNoIndex<T> = {
    [K in keyof T]: string extends K ? never : number extends K ? never : K;
};
type ValuesOf<T> = T extends { [K in keyof T]: infer U; } ? U : never;
type KnownKeys<T> = ValuesOf<KeyToKeyNoIndex<T>>;

interface ThingWithKeysAndIndexSignature {
    [s: string]: any;
    foo: unknown;
    bar: unknown;
}

const demo1: KnownKeys<ThingWithKeysAndIndexSignature> = 'foo';
const demo2: KnownKeys<ThingWithKeysAndIndexSignature> = 'bar';
const demo3: KnownKeys<ThingWithKeysAndIndexSignature> = 'oopsie';

@DanielRosenwasser DanielRosenwasser added the Needs Investigation This issue needs a team member to investigate its status. label May 18, 2021
@DanielRosenwasser DanielRosenwasser added this to the TypeScript 4.3.2 milestone May 18, 2021
@DanielRosenwasser
Copy link
Member

DanielRosenwasser commented May 18, 2021

Possibly related to #44126 - but I don't see how that ties into this one.

@sudall
Copy link

sudall commented Nov 13, 2021

I've run into a potential bug here. Or at the very least it doesn't seem to work as I expect:

https://www.typescriptlang.org/play?#code/C4TwDgpgBAShC2B7AbhAkgOwCYQB4B4AVAPigF4oBvAKCigG0oBpKASwygGsIREAzKISgBDAM5RRwAE7sA5lDzAI2cSwD8UDBFRSoALk0BXeACMIuxcqyqoGrTv3MoAXUeF6TZ9QC+AbmrUoJDMGIgA7hhMPKJEpBTcvAJwSKiYOAQk-tTsSlJ8wgDG0IQAFnIA6qzAJVEgogCC2Gl4AMqsshjCwIZS0DR09KIGkjIYss4Gwhgg-nQmADbCJQaGGJyhEbRQwIgA8vBVK2sbGD4BOeb5RYJlY6VdAKK4SioKz1bi+1VEt7KV1bUGk1sK12p1ur0ADRQADkOy+wBhpH6UD4iEQR3W4QwsygJmEUkxJ383gCAHoyQopFJELoSuYIAZCC0AEwAZhZLKZ4GgMIARAslnyYWxxKFgCJRKIwcIFtAdtsebC+WjEHyoAAfKACgnCgB01AKiAwkm2EEkBiYJ0BPzk92ATxe1jisMFJRhQA

interface ThingWithKeysAndIndexSignature {
  [s: string]: any;
  blah: unknown
  toOmit: unknown
}

interface ThingThatExtends extends Omit<ThingWithKeysAndIndexSignature, 'toOmit'> {
  foo: unknown;
  bar: unknown;
}

// error here: TS2322: Type '"blah"' is not assignable to type '"foo" | "bar"'.
const test: KnownKeys<ThingThatExtends> = 'blah'

as soon as the Omit is removed, it seems to work. Fortunately, I've been able to get around this issue by doing this:

interface ThingThatExtends extends Omit<RemoveIndex<ThingWithKeysAndIndexSignature>, 'toOmit'> {
  foo: unknown;
  bar: unknown;
}

@iflan
Copy link

iflan commented Nov 15, 2021

I'm no TypeScript expert, but I believe the problem here is that Omit<ThingWithKeysAndIndexSignature, 'toOmit'> doesn't work like you expect. Instead of it removing toOmit and otherwise leaving the index signature untouched, it essentially squashes everything into the index signature.

First, let's look at this:

type SpecialValueType = 'a'|'b'|'c';
type IndexValueType = 'm'|'n'|'o';

interface ThingWithKeysAndIndexSignature {
  [s: string]: IndexValueType;
  blah: SpecialValueType;
  toOmit: SpecialValueType;
}

The compiler will complain because SpecialValueType is not assignable to IndexValueType. This shows that the IndexValueType has to extend SpecialValueType. So let's fix the example:

type SpecialValueType = 'a'|'b'|'c';
type IndexValueType = 'm'|'n'|'o'|SpecialValueType;

interface ThingWithKeysAndIndexSignature {
  [s: string]: IndexValueType;
  blah: SpecialValueType;
  toOmit: SpecialValueType;
}

Now the compiler is happy because SpecialValueType is assignable to IndexValueType. This means, essentially, that the compiler can consider any index value to be an IndexValueType except those that it knows are different.

Let's test this out:

function test1(t: ThingWithKeysAndIndexSignature) {
  t.blah = 'm';  // error TS2322: Type '"m"' is not assignable to type 'SpecialValueType'.
}

That works as expected because blah needs to be a SpecialValueType.

So what happens if we now try to use Omit to get rid of the toOmit field?

function test2(t: Omit<ThingWithKeysAndIndexSignature, 'toOmit'>) {
  t.blah = 'm';  // no error!
  t.blah = 'q';  // error TS2322: Type '"q"' is not assignable to type 'IndexValueType'.
}

It may be unexpected to have blah revert to IndexValueType, but it's a logical consequence of how Omit works.

Omit is defined as:

/**
 * Construct a type with the properties of T except for those in type K.
 */
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

The important part here is the keyof T:

const test3: keyof ThingWithKeysAndIndexSignature = true;  // error TS2322: Type 'boolean' is not assignable to type 'keyof ThingWithKeysAndIndexSignature'.

The error is not particularly useful, but it turns out that it is equivalent to string|number:

const test4: keyof ThingWithKeysAndIndexSignature extends string|number ? true : never = true;  // success!

What is the type of string|number indexes? Right, IndexValueType.

@RyanCavanaugh RyanCavanaugh added the Rescheduled This issue was previously scheduled to an earlier milestone label Dec 15, 2021
@weswigham
Copy link
Member

The root cause is basically because string extends keyof T is true, so we fall into the never branch when calculating the implied constraint of infer U. We need to fix up that logic to, rather than instantiate the template to remove the K type parameter, resolve constraints in the template instead. A simple getBaseConstraintOfType instead of our complex instantiation logic may do nicely.

@typescript-bot typescript-bot added the Fix Available A PR has been opened for this issue label Apr 12, 2022
@weswigham weswigham added Bug A bug in TypeScript Domain: Conditional Types The issue relates to conditional types and removed Needs Investigation This issue needs a team member to investigate its status. labels Apr 12, 2022
@sliminality
Copy link

This seems to be related to evaluation order. The original code works if you wrap the first mapped object in parens:

- type KnownKeys<T> = {
+ type KnownKeys<T> = ({
  [K in keyof T]: string extends K ? never : number extends K ? never : K
- } extends { [_ in keyof T]: infer U } ? U : never;
+ }) extends { [_ in keyof T]: infer U } ? U : never;

Playground:

  • 4.3: both versions work
  • 4.4.4: only the version with parens, KnownKeys2<T>, works (still the case in 4.9.4 for me)

This also explains why the workaround #44143 (comment) works simply by separating the first mapped object into its own type, causing it to be evaluated first.

@qwertie
Copy link

qwertie commented Dec 27, 2023

@jakearchibald If I may ask, I don't know how to mentally parse P in keyof Q as R extends S ? T : U. Especially as, I've never seen as in a type context. What does it mean? Can parentheses be added to make it clearer?

Edit: Ah, I figured it out. So the parenthesized version is P in keyof Q as (R extends S ? T : U), where _ in _ as _ is a ternary operator and _ extends _ ? _ : _ is a quaternary operator.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Bug A bug in TypeScript Domain: Conditional Types The issue relates to conditional types Fix Available A PR has been opened for this issue Rescheduled This issue was previously scheduled to an earlier milestone
Projects
None yet
Development

Successfully merging a pull request may close this issue.

10 participants