TypeScript’s Numeric Enums and Their Not-so-obvious Interplay with the typeof and keyof Operators
If you are like me, you once fully understood TypeScript’s enums in all their details (or believed you did), but after not working with them for a while, you find yourself trying to reconstruct the mental model you once had about their semantics. When you ask an AI (in my case, a) the AI assistant built into JetBrains’ IntelliJ IDE and b) perplexity.ai), you often receive wordy but unconvincing or even plainly wrong answers. Unfortunately, the detailed explanations you hoped for in the official documentation are often lacking.
I write this article for my future self; if it helps you too, even better!
The keyof Operator and What “keyof 0” Evaluates to
The keyof
operator can be applied to object types and evaluates to a union of the respective object’s keys. So far, so clear. But what exactly counts as object type in this sense? Interestingly (and I discovered this just today), primitive types, such as number
are also counted as objects in this sense. Therefore, keyof PrimitiveType
for a primitive type PrimitiveType
evaluates to the keys of its prototype. For instance, keyof number
evaluates to type "toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString"
. Consequently, keyof 0
evaluates to the same union for the same reason.
The typeof Operator
The typeof
operator can be applied to identifiers that refer to values and evaluate to “the” type of the respective value. For example:
let v = "hello";
let w: typeof v; // the type of w evaluates to string
However, be aware of this nuance:
const v = "hello";
let w: typeof v; // the type of w evaluates to "hello"
I have placed the above the into quotation marks because it is not immediately obvious (at least not to me) what this type should be in general cases. An educated guess, drawn from these two examples is that it is the most specific type that TypeScript can infer. A const
variable, such as v
in the second example remains constant by definition, allowing the type string
from the first example to be narrowed down to "hello"
in the second example.
typeof is More Complex Than You Expect
But that is not the full story regarding typeof
. It exhibits special, divergent behavior with respect to numeric enums, as we will see below. My inability to find a convincing explanation within the official TypeScript documentation — and the fact that both AIs I consulted ultimately produced either complete nonsense or plausible-sounding but incorrect answers — was my ultimate motivation for writing this article.
Numeric Enums
Numeric enums as types are equivalent to the union of their members’ values. Thus, for
enum Color {Red, Blue}
the type Color
is equivalent to 0 | 1
. One might be tempted to expect that keyof Color
would be equivalent to "Red" | "Blue"
. However, this is not the case; instead, Color
is equivalent to the union of primitive types 0 | 1
and consequently, keyof (0 | 1)
evaluates to "toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString"
as we have seen above.
To derive the union type "Red" | "Blue"
from Color
, we must use keyof typeof Color
, as indicated by the documentation here.
How does it work?
First, we must remember that the typeof
operator doesn’t take types as arguments but rather values (or more precisely: identifiers/variables that refer to values). But wait: Isn’t Color
just that — a type? Yes and no: it is indeed a type but also simultaneously a (runtime) value. It is this latter role that Color
plays when used as an argument of typeof
. So what is the runtime value of Color
? It is (or rather evaluates to) { Red: 0, Blue: 1, 0: "Red", 1: "Blue" }
. The numeric keys 0
and 1
are included since numeric enums — but not string enums — include an inverse mapping.
Thus, if typeof Color
is equivalent to { Red: 0, Blue: 1, 0: "Red", 1: "Blue" }
, then one might expect that keyof typeof Color
would evaluate to "Red" | "Blue" | 0 | 1
“. However, it actually evaluates only to "Red" | "Blue"
. What hapens here? Either typeof
or keyof
exhibits divergent behavior with respect to numeric enums. I asked perplexity.ai about this issue, and it suggested that it was due to the keyof
operator. However, I believe it is actually due to the behavior of the typeof
operator.
Such proof is not completely trivial since IntelliSense does not reveal all necessary information: Perplexity.ai suggested the following code fragment (identifiers adapted):
enum Color {Red, Blue}
type EnumType = typeof Color; // { Red: 0, Blue: 1, 0: "Red", 1: "Blue" } !!! Caution, this claim by perplexity.ai is incorrect.
Hovering over EnumType
does not display the claimed evaluation but merely repeats its definition. If we append a line
let v: EnumType;
then hovering over v
simply displays let v: typeof Color
.
Finally, I believe I have demonstrated that the typeof
operator is indeed responsible through the following code:
enum Color {Red, Blue}
const t1: EQ<typeof Color, {Red: 0, Blue: 1}> = true;
const t2: EQ<typeof Color, {Red: 0, Blue: 1, 0: "Red", 1: "Blue"}> = false;
const t3: typeof Color = {Red: 0, Blue: 1, 0: "Green"};
// const t4: typeof Color = {Red: 0, Blue: 2} // error: Type '2' is not assignable to type 'Color.Blue'.(2322)
// const t5: typeof Color = {Red: 0}; // error: Property 'Blue' is missing in type '{ Red: 0; }' but required in type 'typeof Color'.(2741)
Here EQ
is an auxiliary type that checks for type equivalence. Specifically, EQ<U,V>
evaluates to true
if types U
and V
are equivalent; otherwise it evaluates to false
. Here is a Playground example with the necessary auxiliary definitions.
The assignments for t1
and t2
prove that the typeof
operator omits numeric keys even though the runtime object for Color
includes them (as revealed by console.log(Color)
). The assignments for t3
through t5
further support this claim: For instance:
t3
shows that arbitrary key-value pairs (besides those with keysRed
andBlue
) are possible.- If the numeric key
0
were part oftypeof Color
, then assigning value"Green"
would be illegal, only"Red"
would be allowed. - For key
Blue
, emforcing value1
is indeed validated byt4
, whilet5
confirms that bothRed
andBlue
need to be present as keys (excluding0
or1
).
It would have been beneficial if this behavior of the typeof
operator were clearly documented somewhere within the official resources; unfortunately I could not find it. But now I know where I can refresh my understanding — and hopefully you do too!