Search code examples
typescripttypescript-typingstypeof

The weird types of TypeScript's keyof for objects


I am struggling to understand TypeScript's types when using the keyof type operator on objects. Have a look at the following example:

type TypeA = { [k: number]: boolean };
type AKey = keyof TypeA;
//   ^? type AKey = number

type TypeB = { [k: string]: boolean };
type BKey = keyof TypeB;
//   ^? type BKey = string | number

The TypeScript documentation contains a note saying something like:

Note that in this example, keyof { [k: string]: boolean } is string | number — this is because JavaScript object keys are always coerced to a string, so obj[0] is always the same as obj["0"].

That make it seems as if it should be the exact opposite. That AKey should be number (because they are always coerced to a string), and BKey should just be a string, because the type doesn't allow numbers.

And if that isn't confusing enough, the same doesn't hold true when using Record<>. That seems to be because the definition uses in instead of ::

type Record<K extends string | number | symbol, T> = { [P in K]: T; }

type TypeC = { [k in number]: boolean }; // Record<number, boolean>
type CKey = keyof TypeC;
//   ^? type CKey = number

type TypeD = { [k in string]: boolean }; // Record<string, boolean>
type DKey = keyof TypeD;
//   ^? type DKey = string

All types do allow using both numbers and strings as keys, so the type definitions don't seem to affect that in any way:

const value: TypeA | TypeB | TypeC | TypeD = {
  0: false,
  "1": true,
};

Can anyone help me understand this type circus?


Solution

  • The behavior of the keyof operator when applied to mapped types and to types with index signatures is specified in the implementing pull request at microsoft/TypeScript#23592. The rules are:

    Given an object type X, keyof X is resolved as follows:

    • If X contains a string index signature, keyof X is a union of string, number, and the literal types representing symbol-like properties, otherwise
    • If X contains a numeric index signature, keyof X is a union of number and the literal types representing string-like and symbol-like properties, otherwise
    • keyof X is a union of the literal types representing string-like, number-like, and symbol-like properties.

    TypeScript treats numeric keys of type number a subtype of keys of type string (this is a convenient fiction to support array indexing; object keys are never really number, but instead numeric strings. But I digress, see keyof type operator with indexed signature for more). That means every numeric key is really a string key, but not every string key is a numeric key. An object with a numeric index signature does not claim to support every string key, while an object with a string index signature does claim to support every string key, which includes numeric keys as well.


    As for the difference in behavior of keyof Record<string, T> is string while keyof {[k: string]: T} is string | number, the mapped type behavior was unchanged by the pull request, so it's possible to confirm that it is this way. As for why it's this way, that's harder to say for certain. The closest I can find to a canonical answer for that is a comment in microsoft/TypeScript#31013 by the TS dev team lead:

    As for that behavior not applying to mapped types, well, producing different behavior is why we add different syntax. For deeper research, start at #23592

    So, presumably keyof {[P in K]: T} being K is important to preserve even if K is string, and the reason it is inconsistent with keyof {[k: string]: T} is because the differing syntax allows to choose between the two behaviors. I guess. Anyway it points back to microsoft/TypeScript#23592.