TypeScript enums: How do they work? What can they be used for?

[2020-01-24] dev, javascript, typescript
(Ad, please don’t block)

This blog post answers the following two questions:

  • How do TypeScript’s enums work?
  • What can they be used for?

The basics  

JavaScript has one type with a finite amount of values: boolean, which has the values true and false and no other values. With enums, TypeScript lets you define similar types statically yourself.

Numeric enums  

This is a simple example of an enum:

enum NoYes {
  No,
  Yes, // trailing comma
}

The entries No and Yes are called the members of the enum NoYes. As in object literals, trailing commas are allowed and ignored.

We can use members as if they were literals such as true, 123, or 'abc' – for example:

function toGerman(value: NoYes) {
  switch (value) {
    case NoYes.No:
      return 'Nein';
    case NoYes.Yes:
      return 'Ja';
  }
}
assert.equal(toGerman(NoYes.No), 'Nein');
assert.equal(toGerman(NoYes.Yes), 'Ja');

Enum member values  

Each enum member has a name and a value. The default for enums is to be numeric. That is, each member value is a number:

enum NoYes {
  No,
  Yes,
}
assert.equal(NoYes.No, 0);
assert.equal(NoYes.Yes, 1);

Instead of TypeScript specifying enum member values for us, we can also specify them ourselves:

enum NoYes {
  No = 0,
  Yes = 1,
}

This kind of explicit specification via an equals sign is called an initializer.

We can omit the value of a member if the preceding member value is a number. Then TypeScript increments that value by one and uses it for the current member:

enum Enum {
  A,
  B,
  C = 4,
  D,
  E = 8,
  F,
}
assert.deepEqual(
  [Enum.A, Enum.B, Enum.C, Enum.D, Enum.E, Enum.F],
  [0, 1, 4, 5, 8, 9]
);

Casing of enum member names  

There are several precedents for naming constants (in enums or elsewhere):

  • Traditionally, JavaScript has used all-caps names, which is a convention it inherited from Java and C: Number.MAX_VALUE
  • Well-known symbols are camel-cased and start with lowercase letters because they are related to property names: Symbol.asyncIterator
  • The TypeScript manual uses camel-cased names that start with uppercase letters. This is the standard TypeScript style and we used it for the NoYes enum.

Quoting enum member names  

Similar to JavaScript objects, we can quote the names of enum members:

enum HttpRequestField {
  'Accept',
  'Accept-Charset',
  'Accept-Datetime',
  'Accept-Encoding',
  'Accept-Language',
}
assert.equal(HttpRequestField['Accept-Charset'], 1);

There is no way to compute the names of enum members. Object literals support computed names via square brackets.

String-based enums  

Instead of numbers, we can also use strings as enum member values:

enum NoYes {
  No = 'No',
  Yes = 'Yes',
}
assert.equal(NoYes.No, 'No');
assert.equal(NoYes.Yes, 'Yes');

If an enum is completely string-based, we cannot omit any initializers.

Heterogeneous enums  

The last kind of enums is called heterogeneous. The member values of a heterogeneous enum are a mix of numbers and strings:

enum Enum {
  A,
  B,
  C = 'C',
  D = 'D',
  E = 8,
  F,
}
assert.deepEqual(
  [Enum.A, Enum.B, Enum.C, Enum.D, Enum.E, Enum.F],
  [0, 1, 'C', 'D', 8, 9]
);

Note that the previously mentioned rule applies here, too: We can only omit an initializer if the previous member value is a number.

Heterogeneous enums are not used often because they have few applications.

Alas, TypeScript only supports numbers and strings as enum member values. Other values, such as symbols, are not allowed.

Specifying enum member values  

TypeScript distinguishes three ways of specifying enum member values:

  • Literal enum members are initialized:

    • implicitly or
    • via number literals or string literals (explicitly). So far, we have only used literal members.
  • Constant enum members are initialized via expressions whose results can be computed at compile time.

  • Computed enum members are initialized via arbitrary expressions.

In this list, earlier entries are less flexible, but support more features. The next subsections cover each entry in more detail.

Literal enum members  

An enum member is literal if its value is specified:

  • implicitly or
  • via a number literal (incl. negated number literals) or
  • via a string literal.

If an enum has only literal members, we can use those members as types (similar to how, e.g., number literals can be used as types):

enum NoYes {
  No = 'No',
  Yes = 'Yes',
}
function func(x: NoYes.No) {
  return x;
}

func(NoYes.No); // OK

//@ts-ignore: Argument of type '"No"' is not assignable to
//            parameter of type 'NoYes.No'.
func('No');

//@ts-ignore: Argument of type 'NoYes.Yes' is not assignable to
//            parameter of type 'NoYes.No'.
func(NoYes.Yes);

Additionally, literal enums support exhaustiveness checks (which we’ll look at later).

Constant enum members  

An enum member is constant if its value can be computed at compile time. Therefore, we can either specify its value implicitly (that is, we let TypeScript specify it for us). Or we can specify it explicitly and are only allowed to use the following syntax:

  • Number literals or string literals
  • A reference to a previously defined constant enum member (in the current enum or in a previous enum)
  • Parentheses
  • The unary operators +, -, ~
  • The binary operators +, -, *, /, %, <<, >>, >>>, &, |, ^

This is an example of an enum whose members are all constant (we’ll see soon how that enum is used):

enum Perm {
  UserRead     = 1 << 8, // bit 8
  UserWrite    = 1 << 7,
  UserExecute  = 1 << 6,
  GroupRead    = 1 << 5,
  GroupWrite   = 1 << 4,
  GroupExecute = 1 << 3,
  AllRead      = 1 << 2,
  AllWrite     = 1 << 1,
  AllExecute   = 1 << 0,
}

If an enum has only constant members, we can’t use members as types anymore. But we can still do exhaustiveness checks.

Computed enum members  

The values of computed enum members can be specified via arbitrary expressions. For example:

enum NoYesNum {
  No = 123,
  Yes = Math.random(), // OK
}

This was a numeric enum. String-based enums and heterogeneous enums are more limited. For example, we cannot use method invocations to specify member values:

enum NoYesStr {
  No = 'No',
  //@ts-ignore: Computed values are not permitted in
  //            an enum with string valued members.
  Yes = ['Y', 'e', 's'].join(''),
}

Downsides of numeric enums  

Downside: logging  

When logging members of numeric enums, we only see numbers:

enum NoYes { No, Yes }

console.log(NoYes.No);
console.log(NoYes.Yes);

// Output:
// 0
// 1

Downside: loose type-checking  

When using the enum as a type, the values that are allowed statically are not just those of the enum members – any number is accepted:

enum NoYes { No, Yes }
function func(noYes: NoYes) {}
func(33); // no error!

Why aren’t there stricter static checks? Daniel Rosenwasser explains:

The behavior is motivated by bitwise operations. There are times when SomeFlag.Foo | SomeFlag.Bar is intended to produce another SomeFlag. Instead you end up with number, and you don’t want to have to cast back to SomeFlag.

I think if we did TypeScript over again and still had enums, we’d have made a separate construct for bit flags.

How enums are used for bit patterns is demonstrated soon in more detail.

Recommendation: prefer string-based enums  

My recommendation is to prefer string-based enums (for brevity’s sake, this blog post doesn’t always follow this recommendation):

enum NoYes { No='No', Yes='Yes' }

On one hand, logging output is more useful for humans:

console.log(NoYes.No);
console.log(NoYes.Yes);

// Output:
// 'No'
// 'Yes'

On the other hand, we get stricter type checking:

function func(noYes: NoYes) {}

//@ts-ignore: Argument of type '"abc"' is not assignable
//            to parameter of type 'NoYes'.
func('abc');
//@ts-ignore: Argument of type '"Yes"' is not assignable
//            to parameter of type 'NoYes'.
func('Yes');

Use cases for enums  

Use case: bit patterns  

In the Node.js file system module, several functions have the parameter mode. Its value is used to specify file permissions, via an encoding that is a holdover from Unix:

  • Permissions are specified for three categories of users:
    • User: the owner of the file
    • Group: the members of the group associated with the file
    • All: everyone
  • Per category, the following permissions can be granted:
    • r (read): the users in the category are allowed to read the file
    • w (write): the users in the category are allowed to change the file
    • x (execute): the users in the category are allowed to run the file

That means that permissions can be represented by 9 bits (3 categories with 3 permissions each):

User Group All
Permissions r, w, x r, w, x r, w, x
Bit 8, 7, 6 5, 4, 3 2, 1, 0

Node.js doesn’t do this, but we could use an enum to work with these flags:

enum Perm {
  UserRead     = 1 << 8, // bit 8
  UserWrite    = 1 << 7,
  UserExecute  = 1 << 6,
  GroupRead    = 1 << 5,
  GroupWrite   = 1 << 4,
  GroupExecute = 1 << 3,
  AllRead      = 1 << 2,
  AllWrite     = 1 << 1,
  AllExecute   = 1 << 0,
}

Bit patterns are combined via bitwise Or:

// User can change, read and execute; everyone else can only read and execute
assert.equal(
  Perm.UserRead | Perm.UserWrite | Perm.UserExecute |
  Perm.GroupRead | Perm.GroupExecute |
  Perm.AllRead | Perm.AllExecute,
  0o755);

// User can read and write; group members can read; everyone can’t access at all.
assert.equal(
  Perm.UserRead | Perm.UserWrite | Perm.GroupRead,
  0o640);

An alternative to bit patterns  

The main idea behind bit patterns is that there is a set of flags and that any subset of those flags can be chosen.

Therefore, using real sets to choose subsets is a more self-descriptive way of performing the same task:

enum Perm {
  UserRead,
  UserWrite,
  UserExecute,
  GroupRead,
  GroupWrite,
  GroupExecute,
  AllRead,
  AllWrite,
  AllExecute,
}
function writeFileSync(
  thePath: string, permissions: Set<Perm>, content: string) {
  // ···
}
writeFileSync(
  '/tmp/hello.txt',
  new Set([Perm.UserRead, Perm.UserWrite, Perm.GroupRead]),
  'Hello!');

Use case: multiple constants  

Sometimes, we have sets of constants that belong together:

// Log level:
const off = Symbol('off');
const info = Symbol('info');
const warn = Symbol('warn');
const error = Symbol('error');

This is a good use case for an enum:

enum LogLevel {
  off = 'off',
  info = 'info',
  warn = 'warn',
  error = 'error',
}

The benefits of this enum are:

  • Constant names are grouped and nested inside the namespace LogLevel.
  • We can use the type LogLevel whenever we need one of those constants and TypeScript checks statically that no other values are used.

Use case: more self-descriptive than booleans  

When booleans are used to represent alternatives, then enums are usually a more self-descriptive choice.

Booleanish example: ordered vs. unordered lists  

For example, to represent whether a list is ordered or not, we can use a boolean:

class List1 {
  isOrdered: boolean;
  // ···
}

However, an enum is more self-descriptive and has the additional benefit that we can add more alternatives later if we need to.

enum ListKind { ordered, unordered }
class List2 {
  listKind: ListKind;
  // ···
}

Booleanish example: failure vs. success  

Similarly, we can encode whether an operation succeeded or failed via a boolean or via an enum:

class Result1 {
  success: boolean;
  // ···
}

enum ResultStatus { failure, success }
class Result2 {
  status: ResultStatus;
  // ···
}

Use case: safer string constants  

Consider the following function that creates regular expressions.

const GLOBAL = 'g';
const NOT_GLOBAL = '';
type Globalness = typeof GLOBAL | typeof NOT_GLOBAL;

function createRegExp(source: string,
  globalness: Globalness = NOT_GLOBAL) {
    return new RegExp(source, 'u' + globalness);
  }

assert.deepEqual(
  createRegExp('abc', GLOBAL),
  /abc/ug);

Using a string-based enum is more convenient:

enum Globalness {
  Global = 'g',
  notGlobal = '',
}

function createRegExp(source: string, globalness = Globalness.notGlobal) {
  return new RegExp(source, 'u' + globalness);
}

assert.deepEqual(
  createRegExp('abc', Globalness.Global),
  /abc/ug);

Enums at runtime  

TypeScript compiles enums to JavaScript objects. As an example, take the following enum:

enum NoYes {
  No,
  Yes,
}

TypeScript compiles this enum to:

var NoYes;
(function (NoYes) {
  NoYes[NoYes["No"] = 0] = "No";
  NoYes[NoYes["Yes"] = 1] = "Yes";
})(NoYes || (NoYes = {}));

In this code, the following assignments are made:

NoYes["No"] = 0;
NoYes["Yes"] = 1;

NoYes[0] = "No";
NoYes[1] = "Yes";

There are two groups of assignments:

  • The first two assignments map enum member names to values.
  • The second two assignments map values to names. That enables reverse mappings, which we will look at next.

Reverse mappings  

Given a numeric enum:

enum NoYes {
  No,
  Yes,
}

The normal mapping is from member names to member values:

// Static (= fixed) lookup:
assert.equal(NoYes.Yes, 1);

// Dynamic lookup:
assert.equal(NoYes['Yes'], 1);

Numeric enums also support a reverse mapping from member values to member names:

assert.equal(NoYes[1], 'Yes');

String-based enums at runtime  

String-based enums have a simpler representation at runtime.

Consider the following enum.

enum NoYes {
  No = 'NO!',
  Yes = 'YES!',
}

It is compiled to this JavaScript code:

var NoYes;
(function (NoYes) {
    NoYes["No"] = "NO!";
    NoYes["Yes"] = "YES!";
})(NoYes || (NoYes = {}));

TypeScript does not support reverse mappings for string-based enums.

const enums  

If an enum is prefixed with the keyword const, it doesn’t have a representation at runtime. Instead, the values of its member are used directly.

Compiling non-const enums  

To observe this effect, let us first examine the following non-const enum:

enum NoYes {
  No,
  Yes,
}
function toGerman(value: NoYes) {
  switch (value) {
    case NoYes.No:
      return 'Nein';
    case NoYes.Yes:
      return 'Ja';
  }
}

TypeScript compiles this code to:

var NoYes;
(function (NoYes) {
  NoYes[NoYes["No"] = 0] = "No";
  NoYes[NoYes["Yes"] = 1] = "Yes";
})(NoYes || (NoYes = {}));

function toGerman(value) {
  switch (value) {
    case NoYes.No:
      return 'Nein';
    case NoYes.Yes:
      return 'Ja';
  }
}

Compiling const enums  

This is the same code as previously, but now the enum is const:

const enum NoYes {
  No,
  Yes,
}
function toGerman(value: NoYes) {
  switch (value) {
    case NoYes.No:
      return 'Nein';
    case NoYes.Yes:
      return 'Ja';
  }
}

Now the representation of the enum as a construct disappears and only the values of its members remain:

function toGerman(value) {
  switch (value) {
    case 0 /* No */:
      return 'Nein';
    case 1 /* Yes */:
      return 'Ja';
  }
}

Enums at compile time  

Enums are objects  

TypeScript treats (non-const) enums as if they were objects:

enum NoYes {
  No = 'No',
  Yes = 'Yes',
}
function func(obj: { No: string }) {
  return obj.No;
}
assert.equal(
  func(NoYes), // allowed statically!
  'No');

Exhaustiveness checks for literal enums  

When we accept an enum member value, we often want to make sure that:

  • We don’t receive illegal values.
  • We didn’t forget to consider any enum member values. (This becomes especially relevant if we add new enum member values later on.)

Protecting against illegal values  

In the following code, we take two measures against illegal values:

enum NoYes {
  No = 'No',
  Yes = 'Yes',
}

function toGerman(value: NoYes) {
  switch (value) {
    case NoYes.No:
      return 'Nein';
    case NoYes.Yes:
      return 'Ja';
    default:
      throw new TypeError('Unsupported value: ' + JSON.stringify(value));
  }
}

assert.throws(
  //@ts-ignore: Argument of type '"Maybe"' is not assignable to
  //            parameter of type 'NoYes'.
  () => toGerman('Maybe'),
  /^TypeError: Unsupported value: "Maybe"$/);

The measures are:

  • At compile time, the type NoYes prevents illegal values being passed to the parameter value.
  • At runtime, the default case is used to throw an exception if there is an unexpected value.

Protecting against forgetting cases via exhaustiveness checks  

We can take one more measure. The following code performs an exhaustiveness check: TypeScript will warn us if we forget to consider all enum members.

enum NoYes {
  No = 'No',
  Yes = 'Yes',
}

function throwUnsupportedValue(value: never): never {
  throw new TypeError('Unsupported value: ' + value);
}

function toGerman2(value: NoYes) {
  switch (value) {
    case NoYes.No:
      return 'Nein';
    case NoYes.Yes:
      return 'Ja';
    default:
      throwUnsupportedValue(value);
  }
}

How does the exhaustiveness check work? For every case, TypeScript infers the type of value:

function toGerman2b(value: NoYes) {
  switch (value) {
    case NoYes.No:
      const x: NoYes.No = value;
      return 'Nein';
    case NoYes.Yes:
      const y: NoYes.Yes = value;
      return 'Ja';
    default:
      const z: never = value;
      throwUnsupportedValue(value);
  }
}

In the default case, TypeScript infers the type never for value because we never get there. If however, we add a member .Maybe to NoYes, then the inferred type of value is NoYes.Maybe. And that type is statically incompatible with the type never of the parameter of throwUnsupportedValue(). We therefore get the following error message at compile time:

Argument of type 'NoYes.Maybe' is not assignable to parameter of type 'never'.

Conveniently, this kind of exhaustiveness check also works with if statements:

function toGerman3(value: NoYes) {
  if (value === NoYes.No) {
    return 'Nein';
  } else if (value === NoYes.Yes) {
    return 'Ja';
  } else {
    throwUnsupportedValue(value);
  }
}

An alternative way of checking exhaustiveness  

Alternatively, we also get an exhaustiveness check if we specify a return type for toGerman():

enum NoYes {
  No = 'No',
  Yes = 'Yes',
}
function toGerman(value: NoYes): string {
  switch (value) {
    case NoYes.No:
      const x: NoYes.No = value;
      return 'Nein';
    case NoYes.Yes:
      const y: NoYes.Yes = value;
      return 'Ja';
  }
}

If we add a member to NoYes, then TypeScript complains that toGerman() may return undefined.

Downside of this approach: Alas, this approach does not work with if statements (more information).

keyof and enums  

We can use the keyof type operator to create the type whose elements are the keys of the enum members. When we do so, we need to combine keyof with typeof:

enum HttpRequestKeyEnum {
  'Accept',
  'Accept-Charset',
  'Accept-Datetime',
  'Accept-Encoding',
  'Accept-Language',
}
type HttpRequestKey = keyof typeof HttpRequestKeyEnum;
  // = 'Accept' | 'Accept-Charset' | 'Accept-Datetime' |
  //   'Accept-Encoding' | 'Accept-Language'

function getRequestHeaderValue(request: Request, key: HttpRequestKey) {
  // ···
}

Why do this? It can be more convenient than defining the type HttpRequestKey directly.

Using keyof without typeof  

If we use keyof without typeof, we get a different, less useful, type:

type Keys = keyof HttpRequestKeyEnum;
  // = 'toString' | 'toFixed' | 'toExponential' |
  //   'toPrecision' | 'valueOf' | 'toLocaleString'

keyof HttpRequestKeyEnum is the same as keyof number.

Acknowledgment  

  • Thanks to Disqus user @spira_mirabilis for their feedback to this blog post.