2

I was reading Indexable Types in TypeScript. I was going through this example.

interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}

// Error: indexing with a numeric string might get you a completely separate type of Animal!
interface NotOkay {
  [x: number]: Animal;
// Numeric index type 'Animal' is not assignable to string index type 'Dog'.
  [x: string]: Dog;
}

I am not able to swallow why the above code will create a problem. I went through many articles online but to no avail. Can anyone explain me in simple words with at least an example how above code will create a problem while doing this won't.

interface Okay {
      [x: string]: Dog;
      [x: number]: Animal;
}

It will be much appreciated if correlation with languages like C++, Java (superclass, subclass) is made here too.

Kenpachi Zaraki
  • 384
  • 2
  • 12

1 Answers1

4

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • nice explanation! What is the reason that TS adopts this behavior since array uses number and string as index, and they should have the same type signature, so does TS check different subtypes for number index? – ABOS Dec 29 '20 at 00:34
  • I'm not sure I understand the question. Arrays in TS don't have a `string` index signature. They have a `number` index signature for the elements, and a bunch of individual sting literal indices like `length` & `push`, none of which are numeric-like strings. If arrays had a `string` index signature then it would have to be at least as wide as the union of all the other property types... but it doesn't have one so there's no issue like this. – jcalz Dec 29 '20 at 02:01
  • Your comment and this answer made perfect sense. – Eldar Dec 29 '20 at 06:13
  • Nice explanation. Few minor doubts still remain. So, while using interface Okay, we still can't access "breed" with randomProp2. Therefore, it's the same as doing SuperClass spObj = new SubClass() and access the properties of SuperClass. But, in this inheritance case, I can understand the usage that we are running the subclasses property/methods. But, in the above case, since I can't use breed property. What's the use case in having a syntax like interface Okay. Let me know, if I am clear in explaining my doubts. – Kenpachi Zaraki Dec 29 '20 at 07:00
  • @jcalz, you are right, I forgot to check the type definition for array, I thought since we can do `let a = [1,2,3]; a['0']=11` in javascript, TS will allow numeric string as index signature. But it does not seem the case. – ABOS Dec 29 '20 at 12:11
  • 1
    @KenpachiZaraki In `Okay` you can access `breed` if you know you have a `number` key, like [this](https://tsplay.dev/yNa8Pw). But as for use cases, I'm not sure why someone would actually want both a `number` and `string` index... maybe an expando array like [this](https://tsplay.dev/KWz43N)? I think the behavior of both a number-and-string index signature is not so much a design but a logical consequence of the rules for index signatures. – jcalz Dec 29 '20 at 15:00
  • @jcalz . There was some abmiguity in my mind regarding the links that you have shared. In this [linkA](https://cutt.ly/rjrQ8Qv), at line 60, you haven't considered if randomProp2 can be undefined whereas in this [linkB](https://cutt.ly/EjrWhMW), at line 21/22, you have considered the case. I find the usage kinda inconsistent. Can you help me in understanding why it is happening. – Kenpachi Zaraki Dec 30 '20 at 18:18
  • 1
    That's a different issue with index signatures (and getting out of scope for this question, probably): `undefined` is not, by default, included in the domain of index signature properties even though not all keys are guaranteed present. See [microsoft/TypeScript#13778](https://github.com/microsoft/TypeScript/issues/13778) for discussion, and see the `--noUncheckedIndexedAccess` compiler flag added in TS4.1 [doc link](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-1.html#checked-indexed-accesses---nouncheckedindexedaccess) as a possible way to deal with it. – jcalz Dec 30 '20 at 19:34