Enumify: better enums for JavaScript
In this blog post, I present enumify, a library for implementing enums in JavaScript. The approach it takes is inspired by Java’s enums.
Enum patterns
The following is a naive enum pattern for JavaScript:
const Color = {
RED: 0,
GREEN: 1,
BLUE: 2,
}
This implementation has several problems:
- Logging: If you log an enum value such as
Color.RED
, you don’t see its name. - Type safety: Enum values are not unique, they can be mixed up with other values.
- Membership check: You can’t easily check whether a given value is an element of
Color
.
We can fix problem #1 by using strings instead of numbers as enum values:
const Color = {
RED: 'RED',
GREEN: 'GREEN',
BLUE: 'BLUE',
}
We additionally get type safety if we use symbols as enum values:
const Color = {
RED: Symbol('RED'),
GREEN: Symbol('GREEN'),
BLUE: Symbol('BLUE'),
}
console.log(String(Color.RED));
// Symbol(RED)
One problem with symbols is that you need to convert them to strings explicitly, you can’t coerce them (e.g. via +
or inside template literals):
console.log('Color: '+Color.RED)
// TypeError: Cannot convert a Symbol value to a string
We still don’t have a simple membership test. Using a custom class for enums gives us that. Additionally, everything becomes more customizable:
class Color {
constructor(name) {
this.name = name;
}
toString() {
return `Color.${this.name}`;
}
}
Color.RED = new Color('RED');
Color.GREEN = new Color('GREEN');
Color.BLUE = new Color('BLUE');
console.log(Color.RED); // Color.RED
// Membership test:
console.log(Color.GREEN instanceof Color); // true
However, this solution is slightly verbose. Let’s use a library to fix that.
The library enumify
The library enumify
lets you turn classes into enums. It is available on GitHub and npm. This is how you would implement the running example via it:
import {Enum} from 'enumify';
class Color extends Enum {}
Color.initEnum(['RED', 'GREEN', 'BLUE']);
console.log(Color.RED); // Color.RED
console.log(Color.GREEN instanceof Color); // true
The enum is set up via initEnum()
, a static method that Color
inherits from Enum
.
The library “closes” the class Color
: After Color.initEnum()
, you can’t create any new instances:
> new Color()
Error: Enum classes can’t be instantiated
Properties of enum classes
enumValues
Enums get a static property enumValues
, which contains an Array with all enum values:
for (const c of Color.enumValues) {
console.log(c);
}
// Output:
// Color.RED
// Color.GREEN
// Color.BLUE
The values are listed in the order in which they were added to the enum class. As explained later, you can also call initEnum()
with an object (vs. an Array). Even then, enumValues
has the expected structure, because objects record the order in which properties are added to them.
enumValueOf()
The inherited tool method enumValueOf()
maps names to values:
> Color.enumValueOf('RED') === Color.RED
true
This method is useful for parsing enum values (e.g. if you want to retrieve them from JSON data).
Properties of enum values
Enumify adds two properties to every enum value:
name
: the name of the enum value.
> Color.BLUE.name
'BLUE'ordinal
: the position of the enum value within the ArrayenumValues
.
> Color.BLUE.ordinal
2
Advanced features
Custom properties for enum values
initEnum()
also accepts an object as its parameter. That enables you to add properties to enum values.
class TicTacToeColor extends Enum {}
// Alas, data properties don’t work, because the enum
// values (TicTacToeColor.X etc.) don’t exist when
// the object literals are evaluated.
TicTacToeColor.initEnum({
O: {
get inverse() { return TicTacToeColor.X },
},
X: {
get inverse() { return TicTacToeColor.O },
},
});
console.log(TicTacToeColor.O.inverse); // TicTacToeColor.X
Another use case for this feature is defining commands for a user interface:
class Command extends Enum {}
Command.initEnum({
CLEAR: {
description: 'Clear all entries',
run() { /* ··· */ },
},
ADD_NEW: {
description: 'Add new',
run() { /* ··· */ },
},
});
console.log('Available commands:');
for (let cmd of Command.enumValues) {
console.log(cmd.description);
}
// Output:
// Available commands:
// Clear all entries
// Add new
The instance-specific method run()
executes the command. enumValues
enables us to list all available commands.
Custom prototype methods
If you want all enum values to have the same method, you simply add it to the enum class:
class Weekday extends Enum {
isBusinessDay() {
switch (this) {
case Weekday.SATURDAY:
case Weekday.SUNDAY:
return false;
default:
return true;
}
}
}
Weekday.initEnum([
'MONDAY', 'TUESDAY', 'WEDNESDAY',
'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY']);
console.log(Weekday.SATURDAY.isBusinessDay()); // false
console.log(Weekday.MONDAY.isBusinessDay()); // true
Arbitrary enum values
One occasionally requested feature for enums is that enum values be numbers (e.g. for flags) or strings (e.g. to compare with values in HTTP headers). That can be achieved by making those values properties of enum values. For example:
class Mode extends Enum {}
Mode.initEnum({
USER_R: {
n: 0b100000000,
},
USER_W: {
n: 0b010000000,
},
USER_X: {
n: 0b001000000,
},
GROUP_R: {
n: 0b000100000,
},
GROUP_W: {
n: 0b000010000,
},
GROUP_X: {
n: 0b000001000,
},
ALL_R: {
n: 0b000000100,
},
ALL_W: {
n: 0b000000010,
},
ALL_X: {
n: 0b000000001,
},
});
assert.strictEqual(
Mode.USER_R.n | Mode.USER_W.n | Mode.USER_X.n |
Mode.GROUP_R.n | Mode.GROUP_X.n |
Mode.ALL_R.n | Mode.ALL_X.n,
0o755);
assert.strictEqual(
Mode.USER_R.n | Mode.USER_W.n | Mode.USER_X.n |
Mode.GROUP_R.n,
0o740);
State machines via enums
Enums help with implementing state machines. This is an example:
class Result extends Enum {}
Result.initEnum(['ACCEPTED', 'REJECTED']);
class State extends Enum {}
State.initEnum({
START: {
enter(iter) {
const {value,done} = iter.next();
if (done) {
return Result.REJECTED;
}
switch (value) {
case 'A':
return State.A_SEQUENCE;
default:
return Result.REJECTED;
}
}
},
A_SEQUENCE: ···,
B_SEQUENCE: ···,
ACCEPT: {
enter(iter) {
return Result.ACCEPTED;
}
},
});
function runStateMachine(str) {
let iter = str[Symbol.iterator]();
let state = State.START;
while (true) {
state = state.enter(iter);
switch (state) {
case Result.ACCEPTED:
return true;
case Result.REJECTED:
return false;
}
}
}
runStateMachine('AABBB'); // true
runStateMachine('AA'); // false
runStateMachine('AABBC'); // false
Built-in enums for JavaScript?
This is a Gist sketching what built-in enums could look like. For example:
enum Color {
RED, GREEN, BLUE
}
enum TicTacToeColor {
O {
get inverse() { return TicTacToeColor.X }
},
X {
get inverse() { return TicTacToeColor.O }
},
}
enum Weekday {
MONDAY, TUESDAY, WEDNESDAY,
THURSDAY, FRIDAY, SATURDAY, SUNDAY;
isBusinessDay() {
switch (this) {
case Weekday.SATURDAY:
case Weekday.SUNDAY:
return false;
default:
return true;
}
}
}
enum Mode {
USER_R {
n: 0b100000000,
},
USER_W {
n: 0b010000000,
},
USER_X {
n: 0b001000000,
},
GROUP_R {
n: 0b000100000,
},
GROUP_W {
n: 0b000010000,
},
GROUP_X {
n: 0b000001000,
},
ALL_R {
n: 0b000000100,
},
ALL_W {
n: 0b000000010,
},
ALL_X {
n: 0b000000001,
},
}
Enums in TypeScript
TypeScript has built-in support for enums:
enum Color {
RED, GREEN, BLUE
}
This is how the enum is implemented:
var Color;
(function (Color) {
Color[Color["RED"] = 0] = "RED";
Color[Color["GREEN"] = 1] = "GREEN";
Color[Color["BLUE"] = 2] = "BLUE";
})(Color || (Color = {}));
This code makes the following assignments:
Color["RED"] = 0;
Color["GREEN"] = 1;
Color["BLUE"] = 2;
Color[0] = "RED";
Color[1] = "GREEN";
Color[2] = "BLUE";
TypeScript’s enums have all the disadvantages mentioned for the first enum example earlier: No names for logging, no type safety and no membership tests. You can’t customize these enums, either.
Comments
Post a Comment