TL;DR:
- Objects in JS/TS don't really have keys of type
number; they are actually a special type of string keys.
- Index signatures in TS cannot conflict with other index signatures or properties.
- Since a "
number" key is actually a special kind of string key, any number index signature property must be assignable to a string index signature property if it exists.
One possibly confusing issue is that JavaScript doesn't have truly numeric keys; keys in JavaScript are either symbols or strings... even for something like arrays which are usually thought of as having numeric indices.
When you index into an object with a key of any type other than symbol, JS will coerce it to a string if it isn't already one. So while you can think of a one-element array of having an element at index 0, it's more technically correct to say it has an element at index "0":
const arr: [string] = [""];
const arrKeys: string[] = Object.keys(arr);
console.log(arrKeys) // ["0"]
console.log(arrKeys.includes(0 as any)) // false
console.log(arrKeys.includes("0")) // true
arr[0] = "foo";
arr["0"] = "bar";
console.log(arr[0]); // "bar";
TypeScript allows you to specify an index signature of key type number to represent array-like element access, but it doesn't change the fact that numeric keys are coerced to strings. It would be more accurate if the number index signature used a type like NumericString instead. But there is no such type exposed in TypeScript. (Aside: well, TypeScript 4.1's template literal types actually give you a way to represent such a type as
type NumericString = `${number}`;
but it didn't exist when numeric index signatures were added.) So while in general number is not a subtype of string, for keys, you can think of number as a type of string.
The next possible point of confusion is that TypeScript doesn't view index signatures as an "exception". If you add an index signature, it must not conflict with any other properties. (see this q/a for more info.) For example, the type below has a problem with its baz member:
interface A {
foo: string; // okay
bar: number; // okay
baz: boolean; // <-- error! boolean not assignable to string | number
[k: string]: string | number;
}
The index signature [k: string]: string | number means "if you read a property from A with any key of type string, you will get a value of type string | number." The foo property is compatible, because the key "foo" is a string, and the value type string is assignable to string | number. The bar property is compatible, because the key "bar" is a string, and the value type number is assignable to string | number. But baz is in error. The key "baz" is a string, but it violates the index signature; boolean is not assignable to string | number.
You cannot use index signatures like the above to say "well, the property at key "baz" is a boolean but every other string-keyed property has a value of type string | number. It would be nice to have a way to say that, (see microsoft/TypeScript#17687 for a request for this) but index signatures don't work that way.
It helps to think that the key "baz" is a special case of string, so its property type can be a special case of string | number, and boolean doesn't work.
So, let's put those together:
interface NotOkay {
[x: number]: Animal; // error! Animal not assignable to Dog
[x: string]: Dog;
}
Let's say I have a value of type NotOkay and I index into it with one of its keys:
const notOkay: NotOkay = {
str: dog,
123: animal
}
const randomKey = Object.keys(notOkay)[Math.random() < 0.5 ? 0 : 1]; // string
const randomProp = notOkay[randomKey] // Dog?
console.log(randomProp.breed.toUpperCase()); // either LAB or runtime error?
This will possibly lead to a runtime error, because the key 123 is actually "123", a string. The string index signature for NotOkay says that every property at a string key will be of type Dog. But wait, the number index signature is incompatible with that. If I go ahead and treat every string-indexed property as type Dog, I will have problems with some of these supposed Dogs not having a breed.
So the number index signature is a problem for that reason. It helps to think that the key type number is a special case of string, so its property type can be a special case of Dog, and Animal doesn't work.
If you switch Dog and Animal around, the problem goes away:
interface Okay {
[x: string]: Animal;
[x: number]: Dog;
}
const okay: Okay = {
str: animal,
123: dog
}
const randomKey2 = Object.keys(okay)[Math.random() < 0.5 ? 0 : 1]; // string
const randomProp2 = okay[randomKey2] // Animal
console.log(randomProp2.name.toUpperCase()); // no error here, FIDO or FLUFFY
Since number keys are a special case of string keys, and Dog is a special case of Animal, everything works nicely. Your property of unknown string key is known to be an Animal. It's okay if you treat a Dog like an Animal, because it is one.
Playground link to code