Search code examples
javascripttypescriptlanguage-design

Why does the TypeScript compiler compiles enums to functions?


TL;DR

The Typescript compiler compiles enums to functions, and it does so in a seemingly peculiar way. What are the benefits?

Note:

  1. This question is not opinion-based. I am looking for objective, verifiable ways in which the compiled JavaScript code is better than the trivial option. I am not asking about the thinking process of the designers -- we cannot read their minds.

  2. This question is not a duplicate. At least not a duplicate of the following two questions:


And now in detail

Suppose I define the following enum in TypeScript:

enum Color
{
    Red,
    Green,
    Blue
}

This compiles to the following IIFE:

var Color;
(function (Color) {
    Color[Color["Red"] = 0] = "Red";
    Color[Color["Green"] = 1] = "Green";
    Color[Color["Blue"] = 2] = "Blue";
})(Color || (Color = {}));

Why do we need the IIFE? Furthermore, since Color is presumably known to be undefined, and hence falsy, Color || (Color = {}) is seemingly guaranteed to be evaluated to Color = {}. So what is the point of this expression?

I would presume an equivalent yet simpler implementation would look something like this:

var Color = {}; // why not let?
Color[Color["Red"] = 0] = "Red";
Color[Color["Green"] = 1] = "Green";
Color[Color["Blue"] = 2] = "Blue";

What is wrong with this approach?


Solution

  • A TypeScript enum declaration creates the equivalent of a namespace that holds both the enum members, as well as the types of these members. Your Color enum is quite similar to the following:

    namespace Color {
      export const Red = 0;
      export type Red = typeof Red;  
      export const Green = 1;
      export type Green = typeof Green;
      export const Blue = 2;
      export type Blue = typeof Blue;  
    }
    type Color = typeof Color[keyof typeof Color];
    

    (although the above doesn't provide reverse mappings; these can also be emulated but to do so would be too much of a digression here.)


    Furthermore, TypeScript supports namespace merging, where multiple namespaces of the same name are combined. A namespace is "open" in this sense:

    namespace Foo {
      export const x = "abc";
    }
    
    namespace Foo {
      export const y = Math.PI;
    }
    
    console.log(
      Foo.x.toUpperCase(), Foo.y.toFixed(2)
    ); // "ABC", "3.14"
    

    That means you can also merge enums:

    enum Bar {
      x = 1
    }
    
    enum Bar {
      y = 2
    }
    

    or even merge namespaces and enums together:

    namespace Bar {
      export function f() { console.log(Bar.x, Bar.y) }
    }
    
    Bar.f(); // 1, 2
    

    In order for that to work in JavaScript, it means that a variable with the name of the namespace/enum must exist, but if it already exists you just want to modify it and not replace it. That means you don't want to assign anything to it at all. Hence output like:

    var Bar;
    
    (function (Bar) {
        Bar[Bar["x"] = 1] = "x";
    })(Bar || (Bar = {}));
    
    (function (Bar) {
        Bar[Bar["y"] = 2] = "y";
    })(Bar || (Bar = {}));
    
    (function (Bar) {
        function f() { console.log(Bar.x, Bar.y); }
        Bar.f = f;
    })(Bar || (Bar = {}));
    

    Each block will use an existing Bar value if it is already defined, otherwise it will assign a fresh empty object to Bar and use that instead. The IIFE is presumably for scoping, to keep unexported namespace variables private (including var delcarations so block scope wouldn't be appropriate either, even if your target runtime supports it).


    So there you go; an enum is implemented as a namespace and namespaces can be merged, so you get IIFEs.

    Playground link to code