microsoft/TypeScript

KnownKeys<T> breaking change in 4.3.1-rc

jakearchibald opened this issue ยท 9 comments

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

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

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
;

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';

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

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 commented

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.

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.

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.

@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.