Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do the different enum variants work in TypeScript?

TypeScript has a bunch of different ways to define an enum:

enum Alpha { X, Y, Z }
const enum Beta { X, Y, Z }
declare enum Gamma { X, Y, Z }
declare const enum Delta { X, Y, Z }

If I try to use a value from Gamma at runtime, I get an error because Gamma is not defined, but that's not the case for Delta or Alpha? What does const or declare mean on the declarations here?

There's also a preserveConstEnums compiler flag -- how does this interact with these?

like image 539
Ryan Cavanaugh Avatar asked Oct 14 '22 14:10

Ryan Cavanaugh


People also ask

How does TypeScript enum work?

In TypeScript, enums, or enumerated types, are data structures of constant length that hold a set of constant values. Each of these constant values is known as a member of the enum. Enums are useful when setting properties or values that can only be a certain number of possible values.

Can enum extend another enum TypeScript?

The short answer is no, you can't extend enums because TypeScript offers no language feature to extend them.

How do I get all the enum values in TypeScript?

To get all enum values as an array, pass the enum to the Object. values() method, e.g. const values = Object. values(StringEnum) . The Object.

What is difference between type and enum in TypeScript?

Enums allow us to define or declare a collection of related values that can be numbers or strings as a set of named constants. Unlike some of the types available in TypeScript, enums are preprocessed and are not tested at compile time or runtime.


2 Answers

There are four different aspects to enums in TypeScript you need to be aware of. First, some definitions:

"lookup object"

If you write this enum:

enum Foo { X, Y }

TypeScript will emit the following object:

var Foo;
(function (Foo) {
    Foo[Foo["X"] = 0] = "X";
    Foo[Foo["Y"] = 1] = "Y";
})(Foo || (Foo = {}));

I'll refer to this as the lookup object. Its purpose is twofold: to serve as a mapping from strings to numbers, e.g. when writing Foo.X or Foo['X'], and to serve as a mapping from numbers to strings. That reverse mapping is useful for debugging or logging purposes -- you will often have the value 0 or 1 and want to get the corresponding string "X" or "Y".

"declare" or "ambient"

In TypeScript, you can "declare" things that the compiler should know about, but not actually emit code for. This is useful when you have libraries like jQuery that define some object (e.g. $) that you want type information about, but don't need any code created by the compiler. The spec and other documentation refers to declarations made this way as being in an "ambient" context; it is important to note that all declarations in a .d.ts file are "ambient" (either requiring an explicit declare modifier or having it implicitly, depending on the declaration type).

"inlining"

For performance and code size reasons, it's often preferable to have a reference to an enum member replaced by its numeric equivalent when compiled:

enum Foo { X = 4 }
var y = Foo.X; // emits "var y = 4";

The spec calls this substitution, I will call it inlining because it sounds cooler. Sometimes you will not want enum members to be inlined, for example because the enum value might change in a future version of the API.


Enums, how do they work?

Let's break this down by each aspect of an enum. Unfortunately, each of these four sections is going to reference terms from all of the others, so you'll probably need to read this whole thing more than once.

computed vs non-computed (constant)

Enum members can either be computed or not. The spec calls non-computed members constant, but I'll call them non-computed to avoid confusion with const.

A computed enum member is one whose value is not known at compile-time. References to computed members cannot be inlined, of course. Conversely, a non-computed enum member is once whose value is known at compile-time. References to non-computed members are always inlined.

Which enum members are computed and which are non-computed? First, all members of a const enum are constant (i.e. non-computed), as the name implies. For a non-const enum, it depends on whether you're looking at an ambient (declare) enum or a non-ambient enum.

A member of a declare enum (i.e. ambient enum) is constant if and only if it has an initializer. Otherwise, it is computed. Note that in a declare enum, only numeric initializers are allowed. Example:

declare enum Foo {
    X, // Computed
    Y = 2, // Non-computed
    Z, // Computed! Not 3! Careful!
    Q = 1 + 1 // Error
}

Finally, members of non-declare non-const enums are always considered to be computed. However, their initializing expressions are reduced down to constants if they're computable at compile-time. This means non-const enum members are never inlined (this behavior changed in TypeScript 1.5, see "Changes in TypeScript" at the bottom)

const vs non-const

const

An enum declaration can have the const modifier. If an enum is const, all references to its members inlined.

const enum Foo { A = 4 }
var x = Foo.A; // emitted as "var x = 4;", always

const enums do not produce a lookup object when compiled. For this reason, it is an error to reference Foo in the above code except as part of a member reference. No Foo object will be present at runtime.

non-const

If an enum declaration does not have the const modifier, references to its members are inlined only if the member is non-computed. A non-const, non-declare enum will produce a lookup object.

declare (ambient) vs non-declare

An important preface is that declare in TypeScript has a very specific meaning: This object exists somewhere else. It's for describing existing objects. Using declare to define objects that don't actually exist can have bad consequences; we'll explore those later.

declare

A declare enum will not emit a lookup object. References to its members are inlined if those members are computed (see above on computed vs non-computed).

It's important to note that other forms of reference to a declare enum are allowed, e.g. this code is not a compile error but will fail at runtime:

// Note: Assume no other file has actually created a Foo var at runtime
declare enum Foo { Bar } 
var s = 'Bar';
var b = Foo[s]; // Fails

This error falls under the category of "Don't lie to the compiler". If you don't have an object named Foo at runtime, don't write declare enum Foo!

A declare const enum is not different from a const enum, except in the case of --preserveConstEnums (see below).

non-declare

A non-declare enum produces a lookup object if it is not const. Inlining is described above.

--preserveConstEnums flag

This flag has exactly one effect: non-declare const enums will emit a lookup object. Inlining is not affected. This is useful for debugging.


Common Errors

The most common mistake is to use a declare enum when a regular enum or const enum would be more appropriate. A common form is this:

module MyModule {
    // Claiming this enum exists with 'declare', but it doesn't...
    export declare enum Lies {
        Foo = 0,
        Bar = 1     
    }
    var x = Lies.Foo; // Depend on inlining
}

module SomeOtherCode {
    // x ends up as 'undefined' at runtime
    import x = MyModule.Lies;

    // Try to use lookup object, which ought to exist
    // runtime error, canot read property 0 of undefined
    console.log(x[x.Foo]);
}

Remember the golden rule: Never declare things that don't actually exist. Use const enum if you always want inlining, or enum if you want the lookup object.


Changes in TypeScript

Between TypeScript 1.4 and 1.5, there was a change in the behavior (see https://github.com/Microsoft/TypeScript/issues/2183) to make all members of non-declare non-const enums be treated as computed, even if they're explicitly initialized with a literal. This "unsplit the baby", so to speak, making the inlining behavior more predictable and more cleanly separating the concept of const enum from regular enum. Prior to this change, non-computed members of non-const enums were inlined more aggressively.

like image 283
Ryan Cavanaugh Avatar answered Oct 17 '22 19:10

Ryan Cavanaugh


There are a few things going on here. Let's go case by case.

enum

enum Cheese { Brie, Cheddar }

First, a plain old enum. When compiled to JavaScript, this will emit a lookup table.

The lookup table looks like this:

var Cheese;
(function (Cheese) {
    Cheese[Cheese["Brie"] = 0] = "Brie";
    Cheese[Cheese["Cheddar"] = 1] = "Cheddar";
})(Cheese || (Cheese = {}));

Then when you have Cheese.Brie in TypeScript, it emits Cheese.Brie in JavaScript which evaluates to 0. Cheese[0] emits Cheese[0] and actually evaluates to "Brie".

const enum

const enum Bread { Rye, Wheat }

No code is actually emitted for this! Its values are inlined. The following emit the value 0 itself in JavaScript:

Bread.Rye
Bread['Rye']

const enums' inlining might be useful for performance reasons.

But what about Bread[0]? This will error out at runtime and your compiler should catch it. There's no lookup table and the compiler doesn't inline here.

Note that in the above case, the --preserveConstEnums flag will cause Bread to emit a lookup table. Its values will still be inlined though.

declare enum

As with other uses of declare, declare emits no code and expects you to have defined the actual code elsewhere. This emits no lookup table:

declare enum Wine { Red, Wine }

Wine.Red emits Wine.Red in JavaScript, but there won't be any Wine lookup table to reference so it's an error unless you've defined it elsewhere.

declare const enum

This emits no lookup table:

declare const enum Fruit { Apple, Pear }

But it does inline! Fruit.Apple emits 0. But again Fruit[0] will error out at runtime because it's not inlined and there's no lookup table.

I've written this up in this playground. I recommend playing there to understand which TypeScript emits which JavaScript.

like image 39
Kat Avatar answered Oct 17 '22 18:10

Kat