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

Using keyof with key remapping to exclude index signatures doesn't return known keys #41966

Closed
tim-stasse opened this issue Dec 15, 2020 · 26 comments · Fixed by #41976
Closed
Assignees
Labels
Bug A bug in TypeScript Fix Available A PR has been opened for this issue

Comments

@tim-stasse
Copy link

TypeScript Version: 4.2.0-dev.20201211

Search Terms:

key remapping
remap index signature
keyof returns number

Code

type Compute<A> = { [K in keyof A]: Compute<A[K]>; } & {};

type EqualsTest<T> = <A>() => A extends T ? 1 : 0;
type Equals<A1, A2> = EqualsTest<A2> extends EqualsTest<A1> ? 1 : 0;

type Filter<K, I> = Equals<K, I> extends 1 ? never : K;

type OmitIndex<T, I extends string | number> = {
  [K in keyof T as Filter<K, I>]: T[K];
};

type IndexObject = { [key: string]: unknown; };
type FooBar = { foo: "hello"; bar: "world"; };

type WithIndex = Compute<FooBar & IndexObject>;   // { [x: string]: {}; foo: "hello"; bar: "world"; } <-- OK
type WithoutIndex = OmitIndex<WithIndex, string>; // { foo: "hello"; bar: "world"; }                  <-- OK

type FooBarKey = keyof FooBar;             // "foo" | "bar"   <-- OK
type WithIndexKey = keyof WithIndex;       // string | number <-- Expected: string 
type WithoutIndexKey = keyof WithoutIndex; // number          <-- Expected: "foo" | "bar"

Expected behavior:

Using keyof with the above remapped type (WithoutIndex) should return the known keys (i.e. "foo" | "bar").

Actual behavior:

While the key remapping in OmitIndex appears to successfully omit the string index signature (e.g. OmitIndex<WithIndex, string> --> { foo: "hello"; bar: "world"; }); using keyof with the resulting type returns number.

Playground Link:

https://www.typescriptlang.org/play?ts=4.2.0-dev.20201211#code/C4TwDgpgBAwg9gWzAV2BAPAQQHxQLxQDeUA2gNJQCWAdlANYQhwBmUmAugFyyIppbl22ANxQAvlABkRMcIBQc0JCgBRAI7IAhgBsAzgBUIu4On24CWbAAoAlPlyYoEAB5pqAE11R9UAPxQARihuAAZ5JWh1LT0sAIAaNgAmc1UNHQMjE0xkp1cIDy8o9MNjWNx-IND5RXBoADFKbTQAJ3QyBIBJFKKY9qgu3LdPQL8oaggANwhm4KgyaoioAHkESmAOjxdTTsH84eNmmgBzKAAfMeQEACNplMI5KFIKGnpGFm8oTS8Gpum2zuwXG8gnksgUiw27hcSyuACsIABjYD4IikBggbgHY5A5DUOjUOAAd2oojBizqcDgACFNDMCMRmJTuAAiAAWEG02jgzNEV1pLMJcGa2ncPPEC1qUAA6mtWZCXCj4EhUBgKdTaVJ+ptnDD4UiRI8oAB6I2okjOTHAQ7UI5AwiyKCMuAs9mc7m8-lQZmC4Wi0lQdAAWkDyzINWUMuArLgqHlzhRKzWcfQkbl2oSWJtBpNqKdLo5XLFfOaAqFIrFEkNVer1aDIaWYfD9UpNOaZEYKPR7zVrdENZrOeZTuZZy9xZHjzroab0tlcfbIE7b1YqbjferOczJ3O1EuNxmU5UzkgSIg7kt1pOM9TMfW2oXS6YK9lt7XxtNu+u037hsPx8RaDnl6w6jsy45yEAA

Related Issues:

#31143
#41383
#38646

@RyanCavanaugh RyanCavanaugh added the Bug A bug in TypeScript label Dec 15, 2020
@RyanCavanaugh
Copy link
Member

type WithIndexKey = keyof WithIndex is correctly number | string -- when a type has a string index signature, it's legal to index it with both number and string values, so keyof represents that.

WithoutIndexKey being number is super wrong, though 😲

@jcalz
Copy link
Contributor

jcalz commented Jan 11, 2021

I just ran into the following situation which was not resolved by #41713. Below, MappedFoo is {a: string, b: string}, but keyof MappedFoo is never !

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

interface Foo {
   a: number,
   b: number,
   [k: string]: unknown;
}

type MappedFoo = MapKnownKeys<Foo>;
/* type MappedFoo = {
    a: string;
    b: string;
} */
const mappedFoo: MappedFoo = {
   a: "",
   b: "",
}; // okay

type KeysOfMappedFoo = keyof MappedFoo;
// type KeysOfMappedFoo = never 💥
const a: KeysOfMappedFoo = "a"; // error!
//    ~ <-- Type 'string' is not assignable to type 'never'

Playground link

@masaeedu
Copy link
Contributor

masaeedu commented Feb 12, 2021

@RyanCavanaugh Why is it legal to index it with both number and string values? In untyped JS it's legal to add a number to a string, e.g. "n" + 0 + "nsense" will happily produce "n0nsense", but TypeScript sensibly prevents us from doing this. Seems to make sense that it should also prevent us from indexing a type that is explicitly declared as having string keys with a number. If I want { [key: string]: X }, I'll declare that, and if I want { [key: string | number]: X } I'll declare that.

The implicit coercion from numbers from strings is usually fine in situations that are "contravariant" in the key type (e.g. indexing), but it ends up producing all kinds of nonsense that needs to be casted/Extract-ed away when the keys are reified and you try to do something with them.

EDIT: Wow, apparently TypeScript doesn't prevent "n" + 0 + "nsense".

@weswigham
Copy link
Member

weswigham commented Feb 12, 2021

Simply because number slots conflict with string slots of the same name at runtime (since number slots don't actually exist), so the interaction has to be modeled.

@masaeedu
Copy link
Contributor

@weswigham Hmm. I thought about it a bit and I'm still having trouble seeing why it follows that allowing foo[42] = "bar" or const x = foo[42] is desirable, where foo: { [key: string]: X }.

I think what you're trying to get me to understand is that { [key: string | number]: X } is actually more like { [key: stringsthatarentnumbers | number]: X }, but I don't understand how this subtlety affects things in practice.

@RyanCavanaugh
Copy link
Member

Hmm. I thought about it a bit and I'm still having trouble seeing why it follows that allowing foo[42] = "bar" or const x = foo[42] is desirable, where foo: { [key: string]: X }.

Making you write foo["42"] instead doesn't really accomplish anything. The number -> string coercion here is not unpredictable in normal usage.

@masaeedu
Copy link
Contributor

masaeedu commented Feb 12, 2021

@RyanCavanaugh I mean you said it yourself:

type WithIndexKey = keyof WithIndex is correctly number | string -- when a type has a string index signature, it's legal to index it with both number and string values, so keyof represents that.

What it accomplishes is that WithIndexKey doesn't have to be number | string, and in general we can assume keyof { [k: K]: V } == K. Implicitly allowing you to write foo[42] doesn't accomplish much either, it's 2 characters of difference. If the user does want to be indexing an object with numbers, it's safer for them to have explicitly said so with a numeric index signature.

@weswigham
Copy link
Member

Run Object.keys on an object with exclusively "numeric" keys at runtime and you'll probably understand why the relationship needs to be in place.

@masaeedu
Copy link
Contributor

I tried:

> Object.keys({ 0: 10, 1.5: 10, 42: 10 })
[ "0", "42", "1.5" ]

But I don't really see anything I didn't expect. Could you handhold me through what you're saying?

@masaeedu
Copy link
Contributor

Specifically, I don't understand why the result above means rejecting:

declare const foo: { [key: string]: unknown }

// this...
const x = foo[42]

// and this...
foo[42] = "bar"

as type errors is a problem.

@RyanCavanaugh
Copy link
Member

There's nothing unsound about accepting that program, but we can reject it on an arbitrary "looks wrong" basis like we do for other uses. The question is then to what end: What kinds of mistakes are being prevented? What is gained? There are theoretical consistency arguments to be made in both directions but just arguments from gut feel are unlikely to motivate changing behavior that dates back to TS's very first release.

@weswigham
Copy link
Member

A property with the name 0 and "0" are the same property; numeric keys don't actually exist at runtime.

@masaeedu
Copy link
Contributor

numeric keys don't actually exist at runtime.

That's right, and yet they show up in the result of keyof (to facilitate the illusion that they do). The collection of keys of an object actually does exist at runtime, and there are other things you might want to do with a key besides passing it in foo[here]. When you try to do things with this collection, TypeScript tells you the type of the keys is string | number, which is wrong, because:

numeric keys don't actually exist at runtime.

See #25260 for a couple examples of "covariant" usage of keys that are broken by the implicit symbol | number | being inserted into indexing signatures.

@RyanCavanaugh
Copy link
Member

TypeScript tells you the type of the keys is string | number, which is wrong

The motivating example is

function read<T>(obj: T, key: keyof T) {
    return obj[key];
}

// Should be OK because `[1,2,3][1]` is OK
read([1, 2, 3], 1);

At this point we have to start arguing whether arr[0] should even be legal?

@masaeedu
Copy link
Contributor

masaeedu commented Feb 12, 2021

@RyanCavanaugh You're using the key contravariantly, and to quote myself above: things are "usually fine in situations that are "contravariant" in the key type". Try a "covariant" usage in which you actually extract the keys from the object. For example:

declare const keys: <T extends { [k: string]: unknown }>(t: T) => (keyof T)[]

declare const randomString: () => string

keys({ foo: 1, bar: 2 }) // "foo" | "bar"
                         // Good!
keys({ [randomString()]: 1, [randomString()]: 2 }) // string | number 
                                                   // wuh-oh

@RyanCavanaugh
Copy link
Member

Right. So the options are:

  • Make keyof appear to be totally broken when given an array type, or
  • Type an array of strings as (string | number)[], which is correct albeit not as precise as it could be
  • Something else? What are you proposing?

@masaeedu
Copy link
Contributor

masaeedu commented Feb 12, 2021

Don't really get where that first option is coming from. Array has some methods, and should have a numeric indexer. When you read it, it'll just do what it did before:

function read<T>(obj: T, key: keyof T) {
    return obj[key];
}

type MyArray<T> = { arrayMethod: string, [k: number]: T }

declare const myArray: MyArray<boolean>

const thisIsAString = myArray.arrayMethod
const thisIsABoolean = myArray[1]
const thisIsAUnionOfThingsJustLikeInYourReadExample = read(myArray, 1)

Like, keyof { [k: number]: V } = number today, and it should still be number afterwards (I don't recall suggesting any changes to this). By contrast keyof { [k: string]: V } is string | number, and I'm saying it shouldn't be: it should be string.

@tim-stasse
Copy link
Author

If number slots don't exist at runtime and there's a desire to model accessing those "numeric" strings: shouldn't it be the other way around? For example, the current behaviour is:

type numberIndex = keyof { [k: number]: unknown }; // number
type stringIndex = keyof { [k: string]: unknown }; // string | number

but shouldn't it be:

type numberIndex = keyof { [k: number]: unknown }; // string | number
type stringIndex = keyof { [k: string]: unknown }; // string

Of course, while I understand the use case, which is certainly valid, IMO I'm not so sure if the benefits of modelling either of the behaviours I just mentioned outweigh the loss of type information. Considering that given the current behaviour; a covariant usage like in @masaeedu's example above; and the fact that numbers are actually coerced to strings at runtime when used to index objects like arrays; it often creates a scenario where there's no way to know that "number" should be stripped from the resulting type.

@RyanCavanaugh
Copy link
Member

keyof answers this question:

  • Given the operand type, what are legal types that index it?

If you have { prop: T }, it's legal to index that with "prop" and nothing else:

declare const m: { prop: boolean };
m["prop"]; // OK
m[0]; // Not OK
m["zero"]; // Not OK

If you have { [k: number]: U }, it's legal to index that with number, but not string

declare const m: { [k: number]: boolean };
m[0]; // OK
m["zero"]; // Not OK

If you have { [k: string]: U }, it's legal to index that with either number or string

declare const m: { [k: string]: boolean };
m[0]; // OK, per prior discussion
m["zero"]; // Also OK

You can argue that keyof should have been designed to answer some different question, but it wasn't, so that's not how it works. Discussion on this point is entirely hypothetical or counterfactual - keyof was designed to describe valid read indices on types.

You can also argue that keyof should start doing different things when used in other higher-order contexts, but that raises new and difficult problems around how to reason (both from a compiler's perspective and from a developer's perspective) about which behavior you should expect to get when you see keyof appear in a type expression.

@RyanCavanaugh
Copy link
Member

but shouldn't it be: type numberIndex = keyof { [k: number]: unknown }; // string | number

Which line, if any, should be an error in this program under this rule? If no errors, how would we justify this as reasonable behavior?

function read<T>(obj: T, key: keyof T) {
    return obj[key];
}
const arr: { [n: number]: number } = [1, 2, 3];
const m: number = read(arr, "not an array index at all");

@masaeedu
Copy link
Contributor

masaeedu commented Feb 13, 2021

keyof answers this question:

  • Given the operand type, what are legal types that index it?

This seems a bit circular. We're saying that keyof (typeof v) is defined to be the type of expressions k that can occur in an expression v[k]. What expressions k can occur in v[k]? The ones that have type keyof (typeof v). We can actually justify keyof { [k: string]: V } = string or any other change without needing to disagree: now the valid expressions that index v: { [k: string]: V } must have type string, thus by our definition we should have keyof (typeof v) = string, and therefore the valid expressions that index v: { [k: string]: V } must have type string. (...)

Wouldn't it make more sense for keyof (or some equivalent, if it's now too late for keyof) to be defined more abstractly, and for o[k] and o[k] = r and such to merely be operations whose type involves keyof? So we can have desirable properties like keyof { [k: K]: V } = K, but still put in a | number or whatever in the type of ?1[?2]?

@RyanCavanaugh
Copy link
Member

This seems a bit circular.

The behavior of e[0] being allowed to hit a string indexer predates the existence of keyof, and again, is well-considered in light of how JS object keys work. You call it circular, I call it consistent 😉

So we can have desirable properties like keyof { [k: K]: V } = K, but still put in a | number or whatever in the type of ?1[?2]?

This is shuffling the inconsistency around, not removing it. You're proposing that higher-order forms behave differently from lower-order forms, which is a very common source of complaint in other areas.

@masaeedu
Copy link
Contributor

masaeedu commented Feb 13, 2021

There's (at least) two different operations that we want to perform with the keys of an object in JS: indexing an object with them, and enumerating them. I don't know which one is the higher-order form and which one is the lower one: personally I give them equal standing. Given the same object, the types of the keys involved in each operation are related, but differ in a subtle way (because the indexing operation can accept numbers and will implicitly coerce them to strings).

You're saying we've simply defined keyof to produce the key type of the indexing operation, almost as if we had said type (keyof v) = v[_] extends (key: infer k) => unknown ? k : TypeError. A less unwieldy definition would say something about the structure of v (whether it extends object, whether it's a union, whether it is a mapped type, etc.). This would let you define away the problems you are talking about here:

but that raises new and difficult problems around how to reason (both from a compiler's perspective and from a developer's perspective) about which behavior you should expect to get when you see keyof appear in a type expression.

In other words keyof wouldn't have some unpredictable behavior when it appears in a type expression if it was simply defined by cases over the possible type forming expressions in the language.

If keyof (or some equivalent) was defined in this abstract way, the two operations we're talking about (indexing an object and enumerating its keys) can have types that are defined in terms of it. For example if we want to model and tolerate the JS behavior of numbers being implicitly coerced to strings when passed as indices, we can do so in the type of the indexing operation, without allowing this to leak into the type of the object's enumerated keys, or into the type of other operations involving the object's keys.

@tim-stasse
Copy link
Author

Which line, if any, should be an error in this program under this rule? If no errors, how would we justify this as reasonable behavior?

If someone implemented that rule, I'd expect there to be no errors (note that I'm not actually proposing that). I believe it's arguably justifiable using the same logic applied to the current behaviour: because numbers are coerced to strings. Would it be a good idea to allow indexing such an object with arbitrary string values? No, I don't think so (I think we can all agree there). However, we need to ask ourselves why we believe it's okay to index the same object with arbitrary number values?

I understand that TypeScript isn't aiming to be a perfectly sound type system and that it's intended to model existing/common JavaScript patterns, so sure we can justify that we want to allow the current behaviour using that argument. Still, we can reuse that same argument around the other way again! We could argue the other way around is different, since arbitrary strings are not coerced to numbers, but don't forget there are no number indexes in the runtime value.

This (and your comment quoted below) leads me to my next point, which I think is along the same lines of what @masaeedu has just raised, regarding "keyof" having a dual purpose: for both indexing, and enumerating keys.

The behavior of e[0] being allowed to hit a string indexer predates the existence of keyof, and again, is well-considered in light of how JS object keys work. You call it circular, I call it consistent 😉

It looks to me like we should ask ourselves (though maybe you already know the answer to this), what was the actual/original purpose of "keyof" intended to be? Does the current behaviour align with its true meaning, and would we be ok with changing the behaviour if we conclude that it doesn't? Or perhaps we change its purpose? Or if it does, should we look at creating a new operator for correctly enumerating keys?

@tim-stasse
Copy link
Author

@RyanCavanaugh @weswigham

Couldn't we at least agree that the following example (using latest nightly build) shows that the current behaviour is inconsistent and unintuitive?

Playground Link

type Compute<A> = { [K in keyof A]: Compute<A[K]> } & {};

type EqualsTest<T> = <A>() => A extends T ? 1 : 0;
type Equals<A1, A2> = EqualsTest<A2> extends EqualsTest<A1> ? 1 : 0;

type Filter<K, I> = Equals<K, I> extends 1 ? never : K;

type OmitIndex<T, I extends string | number> = {
  [K in keyof T as Filter<K, I>]: T[K];
};

type OmitIndex2<T, I extends string | number> = I extends unknown
  ? {
      [K in keyof T as Filter<K, I>]: T[K];
    }
  : never;

type StringIndexObject = { [key: string]: {} };
type NumberIndexObject = { [key: number]: {} };
type IndexObject = Compute<StringIndexObject & NumberIndexObject>;

type FooBar = { foo: "hello"; bar: "world" };
type FooBar0 = { foo: "hello"; bar: "world"; 0: "0" };
type FooBar1 = { foo: "hello"; bar: "world"; "1": 1 };
type FooBar01 = { foo: "hello"; bar: "world"; 0: "0"; "1": 1 };

type FooBarKey = Compute<keyof FooBar>; // "foo" | "bar"
type FooBar0Key = Compute<keyof FooBar0>; // 0 | "foo" | "bar"
type FooBar1Key = Compute<keyof FooBar1>; // "foo" | "bar" | "1"
type FooBar01Key = Compute<keyof FooBar01>; // 0 | "foo" | "bar" | "1"
FooBar

// Actual: { [x: string]: {}; foo: "hello"; bar: "world"; }
// OK
type FooBarWithStringIndex = Compute<FooBar & StringIndexObject>;

// Actual: { foo: "hello"; bar: "world"; }
// OK
type FooBarWithoutStringIndex = OmitIndex<FooBarWithStringIndex, string>;

// Actual:   string | number
// Expected: string
type FooBarKeyWithStringIndex = Compute<keyof FooBarWithStringIndex>;

// Actual:   number | "foo" | "bar"
// Expected: "foo" | "bar"
type FooBarKeyWithoutStringIndex = Compute<keyof FooBarWithoutStringIndex>;
// Actual: { [x: number]: {}; foo: "hello"; bar: "world"; }
// OK
type FooBarWithNumberIndex = Compute<FooBar & NumberIndexObject>;

// Actual: { foo: "hello"; bar: "world"; }
// OK
type FooBarWithoutNumberIndex = OmitIndex<FooBarWithNumberIndex, number>;

// Actual: number | "foo" | "bar"
// OK
type FooBarKeyWithNumberIndex = Compute<keyof FooBarWithNumberIndex>;

// Actual: "foo" | "bar"
// OK
type FooBarKeyWithoutNumberIndex = Compute<keyof FooBarWithoutNumberIndex>;
// Actual: { [x: string]: {}; [x: number]: {}; foo: "hello"; bar: "world"; }
// OK
type FooBarWithIndex = Compute<FooBar & IndexObject>;

// Actual:   { [x: string]: {}; [x: number]: {}; foo: "hello"; bar: "world"; }
// Expected: { foo: "hello"; bar: "world"; }
type FooBarWithoutIndex1 = OmitIndex<FooBarWithIndex, string | number>;

// Actual: | { [x: number]: {}; foo: "hello"; bar: "world"; }
//         | { [x: string]: {}; foo: "hello"; bar: "world"; }
// OK
type FooBarWithoutIndex2 = OmitIndex2<FooBarWithIndex, string | number>;

// Actual: { foo: "hello"; bar: "world"; }
// OK
type FooBarWithoutIndex3 = OmitIndex<OmitIndex<FooBarWithIndex, string>, number>;

// Actual: { foo: "hello"; bar: "world"; }
// OK
type FooBarWithoutIndex4 = OmitIndex<OmitIndex<FooBarWithIndex, number>, string>;
// Actual: string | number
// OK
type FooBarKeyWithIndex = Compute<keyof FooBarWithIndex>;

// Actual:   string | number
// Expected: "foo" | "bar"
type FooBarKeyWithoutIndex1 = Compute<keyof FooBarWithoutIndex1>;

// Actual: "foo" | "bar"
// OK
type FooBarKeyWithoutIndex2 = Compute<keyof FooBarWithoutIndex2>;

// Actual: "foo" | "bar"
// OK
type FooBarKeyWithoutIndex3 = Compute<keyof FooBarWithoutIndex3>;

// Actual: "foo" | "bar"
// OK
type FooBarKeyWithoutIndex4 = Compute<keyof FooBarWithoutIndex4>;

FooBar0

// Actual: { [x: string]: {}; foo: "hello"; bar: "world"; 0: "0"; }
// OK
type FooBar0WithStringIndex = Compute<FooBar0 & StringIndexObject>;

// Actual: { foo: "hello"; bar: "world"; 0: "0"; }
// OK
type FooBar0WithoutStringIndex = OmitIndex<FooBar0WithStringIndex, string>;

// Actual:   string | n
// Expected: string | 0
type FooBar0KeyWithStringIndex = Compute<keyof FooBar0WithStringIndex>;

// Actual:   number | "foo" | "bar"
// Expected: 0 | "foo" | "bar"
type FooBar0KeyWithoutStringIndex = Compute<keyof FooBar0WithoutStringIndex>;
// Actual: { [x: number]: {}; foo: "hello"; bar: "world"; 0: "0"; }
// OK
type FooBar0WithNumberIndex = Compute<FooBar0 & NumberIndexObject>;

// Actual: { foo: "hello"; bar: "world"; 0: "0"; }
// OK
type FooBar0WithoutNumberIndex = OmitIndex<FooBar0WithNumberIndex, number>;

// Actual: number | "foo" | "bar"
// OK
type FooBar0KeyWithNumberIndex = Compute<keyof FooBar0WithNumberIndex>;

// Actual: "foo" | "bar"
// OK
type FooBar0KeyWithoutNumberIndex = Compute<keyof FooBar0WithoutNumberIndex>;
// Actual: { [x: string]: {}; [x: number]: {}; foo: "hello"; bar: "world"; 0: "0"; }
// OK
type FooBar0WithIndex = Compute<FooBar0 & IndexObject>;

// Actual:   { [x: string]: {}; [x: number]: {}; foo: "hello"; bar: "world"; 0: "0"; }
// Expected: { foo: "hello"; bar: "world"; 0: "0"; }
type FooBar0WithoutIndex1 = OmitIndex<FooBar0WithIndex, string | number>;

// Actual: | { [x: number]: {}; foo: "hello"; bar: "world"; 0: "0"; }
//         | { [x: string]: {}; foo: "hello"; bar: "world"; 0: "0"; }
// OK
type FooBar0WithoutIndex2 = OmitIndex2<FooBar0WithIndex, string | number>;

// Actual: { foo: "hello"; bar: "world"; 0: "0"; }
// OK
type FooBar0WithoutIndex3 = OmitIndex<OmitIndex<FooBar0WithIndex, string>, number>;

// Actual: { foo: "hello"; bar: "world"; 0: "0"; }
// OK
type FooBar0WithoutIndex4 = OmitIndex<OmitIndex<FooBar0WithIndex, number>, string>;
// Actual: string | number
// OK
type FooBar0KeyWithIndex = Compute<keyof FooBar0WithIndex>;

// Actual:   string | number
// Expected: 0 | "foo" | "bar"
type FooBar0KeyWithoutIndex1 = Compute<keyof FooBar0WithoutIndex1>;

// Actual: 0 | "foo" | "bar"
// OK
type FooBar0KeyWithoutIndex2 = Compute<keyof FooBar0WithoutIndex2>;

// Actual:   "foo" | "bar"
// Expected: 0 | "foo" | "bar"
type FooBar0KeyWithoutIndex3 = Compute<keyof FooBar0WithoutIndex3>;

// Actual:   0
// Expected: 0 | "foo" | "bar"
type FooBar0KeyWithoutIndex4 = Compute<keyof FooBar0WithoutIndex4>;

FooBar1

// Actual:   { [x: string]: {}; foo: "hello"; bar: "world"; 1: 1; }
// Expected: { [x: string]: {}; foo: "hello"; bar: "world"; "1": 1; }
type FooBar1WithStringIndex = Compute<FooBar1 & StringIndexObject>;

// Actual:   { foo: "hello"; bar: "world"; 1: 1; }
// Expected: { foo: "hello"; bar: "world"; "1": 1; }
type FooBar1WithoutStringIndex = OmitIndex<FooBar1WithStringIndex, string>;

// Actual:   string | number
// Expected: string
type FooBar1KeyWithStringIndex = Compute<keyof FooBar1WithStringIndex>;

// Actual:   number | "foo" | "bar" | "1"
// Expected: "foo" | "bar" | "1"
type FooBar1KeyWithoutStringIndex = Compute<keyof FooBar1WithoutStringIndex>;
// Actual:   { [x: number]: {}; foo: "hello"; bar: "world"; 1: 1; }
// Expected: { [x: number]: {}; foo: "hello"; bar: "world"; "1": 1; }
type FooBar1WithNumberIndex = Compute<FooBar1 & NumberIndexObject>;

// Actual:   { foo: "hello"; bar: "world"; 1: 1; }
// Expected: { foo: "hello"; bar: "world"; "1": 1; }
type FooBar1WithoutNumberIndex = OmitIndex<FooBar1WithNumberIndex, number>;

// Actual: number | "foo" | "bar" | "1"
// OK
type FooBar1KeyWithNumberIndex = Compute<keyof FooBar1WithNumberIndex>;

// Actual: "foo" | "bar" | "1"
// OK
type FooBar1KeyWithoutNumberIndex = Compute<keyof FooBar1WithoutNumberIndex>;
// Actual:   { [x: string]: {}; [x: number]: {}; foo: "hello"; bar: "world"; 1: 1; }
// Expected: { [x: string]: {}; [x: number]: {}; foo: "hello"; bar: "world"; "1": 1; }
type FooBar1WithIndex = Compute<FooBar1 & IndexObject>;

// Actual:   { [x: string]: {}; [x: number]: {}; foo: "hello"; bar: "world"; 1: 1; }
// Expected: { foo: "hello"; bar: "world"; "1": 1; }
type FooBar1WithoutIndex1 = OmitIndex<FooBar1WithIndex, string | number>;

// Actual:   | { [x: number]: {}; foo: "hello"; bar: "world"; 1: 1; }
//           | { [x: string]: {}; foo: "hello"; bar: "world"; 1: 1; }
// Expected: | { [x: number]: {}; foo: "hello"; bar: "world"; "1": 1; }
//           | { [x: string]: {}; foo: "hello"; bar: "world"; "1": 1; }
type FooBar1WithoutIndex2 = OmitIndex2<FooBar1WithIndex, string | number>;

// Actual:   { foo: "hello"; bar: "world"; 1: 1; }
// Expected: { foo: "hello"; bar: "world"; "1": 1; }
type FooBar1WithoutIndex3 = OmitIndex<OmitIndex<FooBar1WithIndex, string>, number>;

// Actual:   { foo: "hello"; bar: "world"; 1: 1; }
// Expected: { foo: "hello"; bar: "world"; "1": 1; }
type FooBar1WithoutIndex4 = OmitIndex<OmitIndex<FooBar1WithIndex, number>, string>;
// Actual: string | number
// OK
type FooBar1KeyWithIndex = Compute<keyof FooBar1WithIndex>;

// Actual:   string | number
// Expected: "foo" | "bar" | "1"
type FooBar1KeyWithoutIndex1 = Compute<keyof FooBar1WithoutIndex1>;

// Actual: "foo" | "bar" | "1"
// OK
type FooBar1KeyWithoutIndex2 = Compute<keyof FooBar1WithoutIndex2>;

// Actual: "foo" | "bar" | "1"
// OK
type FooBar1KeyWithoutIndex3 = Compute<keyof FooBar1WithoutIndex3>;

// Actual: "foo" | "bar" | "1"
// OK
type FooBar1KeyWithoutIndex4 = Compute<keyof FooBar1WithoutIndex4>;

FooBar01

// Actual:   { [x: string]: {}; foo: "hello"; bar: "world"; 0: "0"; 1: 1; }
// Expected: { [x: string]: {}; foo: "hello"; bar: "world"; 0: "0"; "1": 1; }
type FooBar01WithStringIndex = Compute<FooBar01 & StringIndexObject>;

// Actual:   { foo: "hello"; bar: "world"; 0: "0"; 1: 1; }
// Expected: { foo: "hello"; bar: "world"; 0: "0"; "1": 1; }
type FooBar01WithoutStringIndex = OmitIndex<FooBar01WithStringIndex, string>;

// Actual:   string | number
// Expected: string | 0
type FooBar01KeyWithStringIndex = Compute<keyof FooBar01WithStringIndex>;

// Actual:   number | "foo" | "bar" | "1"
// Expected: 0 | "foo" | "bar" | "1"
type FooBar01KeyWithoutStringIndex = Compute<keyof FooBar01WithoutStringIndex>;
// Actual:   { [x: number]: {}; foo: "hello"; bar: "world"; 0: "0"; 1: 1; }
// Expected: { [x: number]: {}; foo: "hello"; bar: "world"; 0: "0"; "1": 1; }
type FooBar01WithNumberIndex = Compute<FooBar01 & NumberIndexObject>;

// Actual:   { foo: "hello"; bar: "world"; 0: "0"; 1: 1 }
// Expected: { foo: "hello"; bar: "world"; 0: "0"; "1": 1; }
type FooBar01WithoutNumberIndex = OmitIndex<FooBar01WithNumberIndex, number>;

// Actual: number | "foo" | "bar" | "1"
// OK
type FooBar01KeyWithNumberIndex = Compute<keyof FooBar01WithNumberIndex>;

// Actual:   "foo" | "bar" | "1"
// Expected: 0 | "foo" | "bar" | "1"
type FooBar01KeyWithoutNumberIndex = Compute<keyof FooBar01WithoutNumberIndex>;
// Actual:   { [x: string]: {}; [x: number]: {}; foo: "hello"; bar: "world"; 0: "0"; 1: 1; }
// Expected: { [x: string]: {}; [x: number]: {}; foo: "hello"; bar: "world"; 0: "0"; "1": 1; }
type FooBar01WithIndex = Compute<FooBar01 & IndexObject>;

// Actual:   { [x: string]: {}; [x: number]: {}; foo: "hello"; bar: "world"; 0: "0"; 1: 1; }
// Expected: { foo: "hello"; bar: "world"; 0: "0"; "1": 1; }
type FooBar01WithoutIndex1 = OmitIndex<FooBar01WithIndex, string | number>;

// Actual:   | { [x: number]: {}; foo: "hello"; bar: "world"; 0: "0"; 1: 1; }
//           | { [x: string]: {}; foo: "hello"; bar: "world"; 0: "0"; 1: 1; }
// Expected: | { [x: number]: {}; foo: "hello"; bar: "world"; 0: "0"; "1": 1; }
//           | { [x: string]: {}; foo: "hello"; bar: "world"; 0: "0"; "1": 1; }
type FooBar01WithoutIndex2 = OmitIndex2<FooBar01WithIndex, string | number>;

// Actual:   { foo: "hello"; bar: "world"; 0: "0"; 1: 1; }
// Expected: { foo: "hello"; bar: "world"; 0: "0"; "1": 1; }
type FooBar01WithoutIndex3 = OmitIndex<OmitIndex<FooBar01WithIndex, string>, number>;

// Actual:   { foo: "hello"; bar: "world"; 0: "0"; 1: 1; }
// Expected: { foo: "hello"; bar: "world"; 0: "0"; "1": 1; }
type FooBar01WithoutIndex4 = OmitIndex<OmitIndex<FooBar01WithIndex, number>, string>;
// Actual: string | number
// OK
type FooBar01KeyWithIndex = Compute<keyof FooBar01WithIndex>;

// Actual:   string | number
// Expected: 0 | "foo" | "bar" | "1"
type FooBar01KeyWithoutIndex1 = Compute<keyof FooBar01WithoutIndex1>;

// Actual: 0 | "foo" | "bar" | "1"
// OK
type FooBar01KeyWithoutIndex2 = Compute<keyof FooBar01WithoutIndex2>;

// Actual:   "foo" | "bar" | "1"
// Expected: 0 | "foo" | "bar" | "1"
type FooBar01KeyWithoutIndex3 = Compute<keyof FooBar01WithoutIndex3>;

// Actual:   0
// Expected: 0 | "foo" | "bar" | "1"
type FooBar01KeyWithoutIndex4 = Compute<keyof FooBar01WithoutIndex4>;

@RyanCavanaugh
Copy link
Member

Any behavior is going to be "inconsistent" if you squint at it from the right angle since numeric index signatures are purely a fiction used to denote a rough class of objects with common behavior, rather than a real runtime distinction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Bug A bug in TypeScript Fix Available A PR has been opened for this issue
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants