10
\$\begingroup\$

I wrote this tiny library yesterday. The goal was to implement enums in JavaScript with type-safety. I modeled the implementation similar to enums in Java, since that is what I am most familiar with.

It seems to work like you would expect, but I wanted to make sure that what I did makes sense and that there isn't some glaring hole in my implementation, and that enum semantics are appropriately conveyed. Suggestions for improvement are appreciated also.

/**
 * enum.js - Type-safe enums in JavaScript. Modeled after Java enums.
 * Version 1.0.0
 * Written by Vivin Paliath (http://vivin.net)
 * License: BSD License
 * Copyright (C) 2015
 */

var Enum = (function () {
    /**
     * Function to define an enum
     * @param typeName - The name of the enum.
     * @param constants - The constants on the enum. Can be an array of strings, or an object where each key is an enum
     * constant, and the values are objects that describe attributes that can be attached to the associated constant.
     */
    function define(typeName, constants) {

        /** Check Arguments **/
        if (typeof typeName === "undefined") {
            throw new TypeError("A name is required.");
        }

        if (!(constants instanceof Array) && (Object.getPrototypeOf(constants) !== Object.prototype)) {

            throw new TypeError("The constants parameter must either be an array or an object.");

        } else if ((constants instanceof Array) && constants.length === 0) {

            throw new TypeError("Need to provide at least one constant.");

        } else if ((constants instanceof Array) && !constants.reduce(function (isString, element) {
                return isString && (typeof element === "string");
            }, true)) {

            throw new TypeError("One or more elements in the constant array is not a string.");

        } else if (Object.getPrototypeOf(constants) === Object.prototype && !Object.keys(constants).reduce(function (isObject, constant) {
                return Object.getPrototypeOf(constants[constant]) === Object.prototype;
            }, true)) {

            throw new TypeError("One or more constants do not have an associated object-value.");

        }

        var isArray = (constants instanceof Array);
        var isObject = !isArray;

        /** Private sentinel-object used to guard enum constructor so that no one else can create enum instances **/
        function __() { };

        /** Dynamically define a function with the same name as the enum we want to define. **/
        var __enum = new Function(["__"],
            "return function " + typeName + "(sentinel, name, ordinal) {" +
                "if(!(sentinel instanceof __)) {" +
                    "throw new TypeError(\"Cannot instantiate an instance of " + typeName + ".\");" +
                "}" +

                "this.__name = name;" +
                "this.__ordinal = ordinal;" +
            "}"
        )(__);

        /** Private objects used to maintain enum instances for values(), and to look up enum instances for fromName() **/
        var __values = [];
        var __dict = {};

        /** Attach values() and fromName() methods to the class itself (kind of like static methods). **/
        Object.defineProperty(__enum, "values", {
            value: function () {
                return __values;
            }
        });

        Object.defineProperty(__enum, "fromName", {
            value: function (name) {
                var __constant = __dict[name]
                if (__constant) {
                    return __constant;
                } else {
                    throw new TypeError(typeName + " does not have a constant with name " + name + ".");
                }
            }
        });

        /**
         * The following methods are available to all instances of the enum. values() and fromName() need to be
         * available to each constant, and so we will attach them on the prototype. But really, they're just
         * aliases to their counterparts on the prototype.
         */
        Object.defineProperty(__enum.prototype, "values", {
            value: __enum.values
        });

        Object.defineProperty(__enum.prototype, "fromName", {
            value: __enum.fromName
        });

        Object.defineProperty(__enum.prototype, "name", {
            value: function () {
                return this.__name;
            }
        });

        Object.defineProperty(__enum.prototype, "ordinal", {
            value: function () {
                return this.__ordinal;
            }
        });

        Object.defineProperty(__enum.prototype, "valueOf", {
            value: function () {
                return this.__name;
            }
        });

        Object.defineProperty(__enum.prototype, "toString", {
            value: function () {
                return this.__name;
            }
        });

        /**
         * If constants was an array, we can the element values directly. Otherwise, we will have to use the keys
         * from the constants object.
         */
        var _constants = constants;
        if (isObject) {
            _constants = Object.keys(constants);
        }

        /** Iterate over all constants, create an instance of our enum for each one, and attach it to the enum type **/
        _constants.forEach(function (name, ordinal) {
            // Create an instance of the enum
            var __constant = new __enum(new __(), name, ordinal);

            // If constants was an object, we want to attach the provided attributes to the instance.
            if (isObject) {
                Object.keys(constants[name]).forEach(function (attr) {
                    Object.defineProperty(__constant, attr, {
                        value: constants[name][attr]
                    });
                });
            }

            // Freeze the instance so that it cannot be modified.
            Object.freeze(__constant);

            // Attach the instance using the provided name to the enum type itself.
            Object.defineProperty(__enum, name, {
                value: __constant
            });

            // Update our private objects
            __values.push(__constant);
            __dict[name] = __constant;
        });

        /** Define a friendly toString method for the enum **/
        var string = typeName + " { " + __enum.values().map(function (c) {
                return c.name();
            }).join(", ") + " } ";

        Object.defineProperty(__enum, "toString", {
            value: function () {
                return string;
            }
        });

        /** Freeze our private objects **/
        Object.freeze(__values);
        Object.freeze(__dict);

        /** Freeze the prototype on the enum and the enum itself **/
        Object.freeze(__enum.prototype);
        Object.freeze(__enum);

        /** Return the enum **/
        return __enum;
    }

    return {
        define: define
    }

})();

Example of usage:

var Days = Enum.define("Days", ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]);

Days.Monday instanceof Days; // true

Days.Friday.name(); // "Friday"
Days.Friday.ordinal(); // 4

Days.Sunday === Days.Sunday; // true
Days.Sunday === Days.Friday; // false

Days.Sunday.toString(); // "Sunday"

Days.toString() // "Days { Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday } "

Days.values().map(function(e) { return e.name(); }); //["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
Days.values()[4].name(); //"Friday"

Days.fromName("Thursday") === Days.Thursday // true
Days.fromName("Wednesday").name() // "Wednesday"
Days.Friday.fromName("Saturday").name() // "Saturday"
\$\endgroup\$
  • \$\begingroup\$ @plalx I don't know about that. This solution gives you as much type-safety as can be expected from JS. Also, sometimes you don't have the option of choosing another language. \$\endgroup\$ – Vivin Paliath Oct 19 '15 at 18:40
  • \$\begingroup\$ @plalx The instanceof is there just to demonstrate that this is better than using strings or numbers because it has a type associated with it, which means you can do === against actual instances of the enum. I normally write code in Java and I'm fully aware that JS's type system isn't strong at all. Switching to another language is not always an option as I mentioned before, so if I have to use JavaScript, I'd like to have ways to achieve some kind of type-safety; I'm not aiming for anything extremely rigorous. \$\endgroup\$ – Vivin Paliath Oct 19 '15 at 19:00
3
\$\begingroup\$

Why use getters like name() and ordinal() instead of properties? In Java you might use these even when they do nothing more than return a private property so as to future-proof your code: if, one day, you want to add some logic to count the number of times an Enum name is fetched, or otherwise do more than only return a value, then you can do this without having to modify every bit of source that had referenced a property and must now call a function. But there's no need for this kind of future-proofing in JavaScript:

var a = { get x() { return "got x" } }
a.x  // 'got x'

Why should Days.Friday.fromName('Saturday') work? Is this a real, intended feature, or just something that fell out of your code?

Why freeze everything? Object.freeze() calls are easy to add to the following code, I just wonder what the point is.

Do enums have a name for any reason other than to show up in their string representation? Again, easy to add back to the following if you really want it.

Mainly, this strikes me as an incredible amount of work for what it does.

var Enum = {
  define: function() {
    var type = function() {},
        length = arguments.length,
        enums = [],
        string = "Enum { "

    type.prototype.valueOf = type.prototype.toString = function() { return this.name }
    type.fromName = function(name) { return type[name] }
    type.values = function() { return enums }
    type.toString = function() { return string }

    for (var i = 0; i < length; i++) {
      var o = new type
      type[arguments[i]] = o
      o.name = arguments[i]
      o.ordinal = i
      enums.push(o)

      if (i > 0) string += ", "
      string += o.name
    }
    string += " }"

    return type
  }
}

Usage is retained, with one exception:

var Days = Enum.define("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")

console.log(true, Days.Monday instanceof Days)
console.log('Friday', Days.Friday.name)
console.log(4, Days.Friday.ordinal)
console.log(true, Days.Sunday === Days.Sunday)
console.log(false, Days.Sunday === Days.Friday)
console.log('Sunday', Days.Sunday.toString())
console.log('Enum { Monday, Tuesday, ... }', Days.toString())
console.log('["Monday", ...]', Days.values().map(function(e) { return e.name }))
console.log('Friday', Days.values()[4].name)
console.log(true, Days.fromName("Thursday") === Days.Thursday)
console.log('Wednesday', Days.fromName("Wednesday").name)
// console.log('Saturday', Days.Friday.fromName('Saturday').name)

EDIT: I didn't even notice the protection against new instances of the Enum constructor. This seems to me to be an unlikely error... but this kind of protection is easily added with a check against a closed-over lexical variable:

var Terminable = (function() {
  var terminated = false,
      constructor = function() {
        if (terminated)
          throw new TypeError("Cannot instantiate a new instance of a terminated constructor")
      }
  constructor.terminate = function() { terminated = true }
  return constructor
})()

var a = new Terminable
console.log(true, a instanceof Terminable)  // true true
Terminable.terminate()
var b = new Terminable  // TypeError
\$\endgroup\$
  • \$\begingroup\$ name and ordinal would work; that's just my Java background! There's no need to freeze everything; probably just the definition itself. I wrote this at around 3am and so there's probably some stuff in there that doesn't make sense. The fromName on Saturday technically doesn't make sense; I was surprised at the Java behavior too, but I think the rationale is that the constants are instances of the enum itself. Having a name property separates it from toString, since you could have a different toString representation, but here it is kind of redundant. Thanks for the comments! \$\endgroup\$ – Vivin Paliath Nov 3 '15 at 16:50
  • \$\begingroup\$ Oh, and the reason I didn't go the var type = function(){} route is kind of cosmetic - I wanted the name of the enum to show up as the constructor's name. I also used defineProperty as a force of habit. \$\endgroup\$ – Vivin Paliath Nov 3 '15 at 16:53

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.