This blog post answers the following two questions:
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.
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');
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]
);
There are several precedents for naming constants (in enums or elsewhere):
Number.MAX_VALUE
Symbol.asyncIterator
NoYes
enum.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.
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.
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.
TypeScript distinguishes three ways of specifying enum member values:
Literal enum members are initialized:
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.
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) {
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).
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 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.
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(''),
}
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
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 anotherSomeFlag
. Instead you end up withnumber
, and you don’t want to have to cast back toSomeFlag
.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.
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');
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:
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);
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!');
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:
LogLevel
.LogLevel
whenever we need one of those constants and TypeScript checks statically that no other values are used.When booleans are used to represent alternatives, then enums are usually a more self-descriptive choice.
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;
// ···
}
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;
// ···
}
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);
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:
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 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.
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';
}
}
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';
}
}
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');
When we accept an enum member value, we often want to make sure that:
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:
NoYes
prevents illegal values being passed to the parameter value
.default
case is used to throw an exception if there is an unexpected value.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);
}
}
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.
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
.
@spira_mirabilis
for their feedback to this blog post.