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 symbol
s or string
s... 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 Dog
s 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