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
- 4.3.0-beta playground โ types work.
- 4.4.0 playground โ types fail. This issue also exists in 4.3.1-rc but that version isn't in the playground.
๐ป 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:
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;
}
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.