Tackling TypeScript
Please support this book: buy it or donate
(Ad, please don’t block.)

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



This chapter answers the following two questions:

In the next chapter, we take a look at alternatives to enums.

12.1 The basics

boolean is a type with a finite amount of values: false and true. With enums, TypeScript lets us define similar types ourselves.

12.1.1 Numeric enums

This is a numeric enum:

enum NoYes {
  No = 0,
  Yes = 1, // trailing comma
}

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

Explanations:

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');

12.1.2 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');

12.1.3 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 {
  One = 'One',
  Two = 'Two',
  Three = 3,
  Four = 4,
}
assert.deepEqual(
  [Enum.One, Enum.Two, Enum.Three, Enum.Four],
  ['One', 'Two', 3, 4]
);

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.

12.1.4 Omitting initializers

We can omit initializers in two cases:

This is a numeric enum without any initializers:

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

This is a heterogeneous enum where some initializers are omitted:

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

Note that we can’t omit the initializer in line A because the value of the preceding member is not a number.

12.1.5 Casing of enum member names

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

12.1.6 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 property keys via square brackets.

12.2 Specifying enum member values (advanced)

TypeScript distinguishes three kinds of enum members, by how they are initialized:

So far, we have only used literal members.

In the previous list, members that are mentioned earlier are less flexible but support more features. Read on for more information.

12.2.1 Literal enum members

An enum member is literal if its value is specified:

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) { // (A)
  return x;
}

func(NoYes.No); // OK

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

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

NoYes.No in line A is an enum member type.

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

12.2.2 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:

This is an example of an enum whose members are all constant (we’ll see later 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,
}

In general, constant members can’t be used as types. However, exhaustiveness checks are still performed.

12.2.3 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-expect-error: Computed values are not permitted in
  // an enum with string valued members.
  Yes = ['Y', 'e', 's'].join(''),
}

TypeScript does not do exhaustiveness checks for computed enum members.

12.3 Downsides of numeric enums

12.3.1 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

12.3.2 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.

12.3.3 Recommendation: prefer string-based enums

My recommendation is to prefer string-based enums (for brevity’s sake, this chapter 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-expect-error: Argument of type '"abc"' is not assignable
// to parameter of type 'NoYes'.
func('abc');

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

Not even strings that are equal to values of members are allowed (line A).

12.4 Use cases for enums

12.4.1 Use case: bit patterns

In the Node.js file system module, several functions have the parameter mode. It specifies file permissions, via a numeric encoding that is a holdover from Unix:

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);
12.4.1.1 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 straightforward way of performing the same task:

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

12.4.2 Use case: multiple constants

Sometimes, we have sets of constants that belong together:

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',
}

One benefit of the enum is that the constant names are grouped and nested inside the namespace LogLevel.

Another one is that we automatically get the type LogLevel for them. If we want such a type for the constants, we need more work:

type LogLevel =
  | typeof off
  | typeof info
  | typeof warn
  | typeof error
;

For more information on this approach, see §13.1.3 “Unions of symbol singleton types”.

12.4.3 Use case: more self-descriptive than booleans

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

12.4.3.1 Boolean-ish 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;
  // ···
}
12.4.3.2 Boolean-ish example: error handling modes

Similarly, we can specify how to handle errors via a boolean value:

function convertToHtml1(markdown: string, throwOnError: boolean) {
  // ···
}

Or we can do so via an enum value:

enum ErrorHandling {
  throwOnError = 'throwOnError',
  showErrorsInContent = 'showErrorsInContent',
}
function convertToHtml2(markdown: string, errorHandling: ErrorHandling) {
  // ···
}

12.4.4 Use case: better 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);

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

Instead of the string constants, we can use an enum:

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);

assert.deepEqual(
  // @ts-expect-error: Argument of type '"g"' is not assignable to parameter of type 'Globalness | undefined'. (2345)
  createRegExp('abc', 'g'), // error
  /abc/ug);

What are the benefits of this approach?

12.5 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:

12.5.1 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');

One use case for reverse mappings is printing the name of an enum member:

function getQualifiedName(value: NoYes) {
  return 'NoYes.' + NoYes[value];
}
assert.equal(
  getQualifiedName(NoYes.Yes), 'NoYes.Yes');

12.5.2 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.

12.6 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.

12.6.1 Compiling non-const enums

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

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

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

TypeScript compiles this code to:

"use strict";
var NoYes;
(function (NoYes) {
  NoYes["No"] = "No";
  NoYes["Yes"] = "Yes";
})(NoYes || (NoYes = {}));

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

12.6.2 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 "No" /* No */:
      return 'Nein';
    case "Yes" /* Yes */:
      return 'Ja';
  }
}

12.7 Enums at compile time

12.7.1 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');

12.7.2 Safety checks for literal enums

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

Read on for more information. We will be working with the following enum:

enum NoYes {
  No = 'No',
  Yes = 'Yes',
}
12.7.2.1 Protecting against illegal values

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

function toGerman1(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-expect-error: Argument of type '"Maybe"' is not assignable to
  // parameter of type 'NoYes'.
  () => toGerman1('Maybe'),
  /^TypeError: Unsupported value: "Maybe"$/);

The measures are:

12.7.2.2 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.

class UnsupportedValueError extends Error {
  constructor(value: never) {
    super('Unsupported value: ' + value);
  }
}

function toGerman2(value: NoYes) {
  switch (value) {
    case NoYes.No:
      return 'Nein';
    case NoYes.Yes:
      return 'Ja';
    default:
      throw new UnsupportedValueError(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:
      // %inferred-type: NoYes.No
      value;
      return 'Nein';
    case NoYes.Yes:
      // %inferred-type: NoYes.Yes
      value;
      return 'Ja';
    default:
      // %inferred-type: never
      value;
      throw new UnsupportedValueError(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 new UnsupportedValueError(). That’s why we 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 {
    throw new UnsupportedValueError(value);
  }
}
12.7.2.3 An alternative way of checking exhaustiveness

Alternatively, we also get an exhaustiveness check if we specify a return type:

function toGerman4(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 toGerman4() may return undefined.

Downsides of this approach:

12.7.3 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',
}
// %inferred-type: "Accept" | "Accept-Charset" | "Accept-Datetime" |
// "Accept-Encoding" | "Accept-Language"
type HttpRequestKey = keyof typeof HttpRequestKeyEnum;

function getRequestHeaderValue(request: Request, key: HttpRequestKey) {
  // ···
}
12.7.3.1 Using keyof without typeof

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

// %inferred-type: "toString" | "toFixed" | "toExponential" |
// "toPrecision" | "valueOf" | "toLocaleString"
type Keys = keyof HttpRequestKeyEnum;

keyof HttpRequestKeyEnum is the same as keyof number.

12.8 Acknowledgment