Search code examples
javascriptswiftdata-structuresenumslanguage-design

How to implement Swift-like enums with associated values in JavaScript?


The Swift language has a fantastic enum support. Not only can one define a standard enum with cases, but cases can have optional values "associated to them."

For example, taken from the Swift docs:

enum Barcode {
    case UPCA(Int, Int, Int, Int)
    case QRCode(String)
    case Other
}

Such that one could create a Barcode enum by passing in a value, like so:

var productBarcode = Barcode.UPCA(8, 85909, 51226, 3)

and also switch on productBarcode at a later date to retrieve the associated value (a tuple of ints).


I have been trying to implement this kind of enum system in JavaScript (ES5, in particular), but am hitting a wall. What is the best way to structure an enum system, especially one with associated values?


Solution

  • That's notr exactly the way enums work in most languages i know. Usually they are more like a way to type a value as one of these states. Like selecting one value out of a set of possible values. And to ensure type-safety in doing this, unlike with plain integers.

    What you posted in your code, I would call a plain Object with factory-methods.

    Since they are not supported that way by the language you have to implement them in a way that fit's your needs as good as possible. So sum up what behaviour you expect.

    In the mean time a Implementation based on the descriptions i've found on swift enums. hope it comes close to what you expect:

    var odp = {
        ENUMERABLE: 4,
    
        //two helper with Object.defineProperty.
        value: function(obj, prop, v, flags){
            this.configurable = Boolean(flags & odp.CONFIGURABLE);
            this.writable = Boolean(flags & odp.WRITABLE);
            this.enumerable = Boolean(flags & odp.ENUMERABLE);
            this.value = v;
            Object.defineProperty(obj, prop, this);
            this.value = null;  //v may be a function or an object: remove the reference
            return obj;
        }.bind({    //caching the basic definition
            value: null, 
            configurable: false, 
            writable: false, 
            enumerable: false 
        }),
    
        accessor: function(obj, prop, getter, setter){
            this.get = getter || undefined;
            this.set = setter || undefined;
            Object.defineProperty(obj, prop, this);
            this.get = null;
            this.set = null;
            return obj;
        }.bind({ get: null, set: null })
    }
    //make these values immutable
    odp.value(odp, "CONFIGURABLE", 1, odp.ENUMERABLE);
    odp.value(odp, "WRITABLE", 2, odp.ENUMERABLE);
    odp.value(odp, "ENUMERABLE", 4, odp.ENUMERABLE);
    
    
    
    //Policy: 
    //1. I don't f*** care wether the keys on the definition are own or inherited keys.
    //since you pass them to me, I suppose you want me to process them.
    
    //2. If i find some undefined-value i ignore it, as if it wasn't there.
    //use null to represent some "empty" value
    
    //name and extendProto are optional
    function Enum(name, description, extendProto){
        var n = name, d = description, xp=extendProto;
        if(n && typeof n === "object") xp=d, d = n, n = null;
        var xpf = typeof xp === "function" && xp;
        var xpo = typeof xp === "object" && xp;
    
        function type(){ 
            throw new Error("enums are not supposed to be created manually"); 
        }
    
        //abusing filter() as forEach()
        //removing the keys that are undefined in the same step.
        var keys = Object.keys(d).filter(function(key){
            var val = d[key];
            if(val === undefined) return false;
            var proto = Object.create(type.prototype);
    
            //your chance to extend the particular prototype with further properties
            //like adding the prototype-methods of some other type
            var props = xpf || xpo && xpo[key];
            if(typeof props === "function") 
                props = props.call(type, proto, key, val);
    
            if(props && typeof props === "object" && props !== proto && props !== val){
                var flags = odp.CONFIGURABLE+odp.WRITABLE;
                for(var k in props) props[k]===undefined || odp.value(proto, k, props[k], flags);
                if("length" in props) odp.value(props, "length", props.length, flags);
            }
    
            if(typeof val === "function"){
                //a factory and typedefinition at the same type
                //call this function to create a new object of the type of this enum
                //and of the type of this function at the same time
                type[key] = function(){
                    var me = Object.create(proto);
                    var props = val.apply(me, arguments);
                    if(props && typeof props === "object" && props !== me){
                        for(var k in props) props[k]===undefined || odp.value(me, k, props[k], odp.ENUMERABLE);
                        if("length" in props) odp.value(me, "length", props.length);
                    }
                    return me;
                }
                //fix the fn.length-property for this factory
                odp.value(type[key], "length", val.length, odp.CONFIGURABLE);
    
                //change the name of this factory
                odp.value(type[key], "name", (n||"enum")+"{ "+key+" }" || key, odp.CONFIGURABLE);
    
                type[key].prototype = proto;
                odp.value(proto, "constructor", type[key], odp.CONFIGURABLE);
    
            }else if(val && typeof val === "object"){
                for(var k in val) val[k] === undefined || odp.value(proto, k, val[k]);
                if("length" in val) odp.value(proto, "length", val.length);
                type[key] = proto;
    
            }else{
                //an object of the type of this enum that wraps the primitive
                //a bit like the String or Number or Boolean Classes
    
                //so remember, when dealing with this kind of values, 
                //you don't deal with actual primitives
                odp.value(proto, "valueOf", function(){ return val; });     
                type[key] = proto;
    
            }
    
            return true;
        });
    
        odp.value(type, "name", n || "enum[ " + keys.join(", ") + " ]", odp.CONFIGURABLE);
        Object.freeze(type);
    
        return type;
    }
    

    Beware, this code may need some further modification. Examples:

    Factories

    function uint(v){ return v>>>0 }
    
    var Barcode = Enum("Barcode", {
        QRCode: function(string){
            //this refers to an object of both types, Barcode and Barcode.QRCode
            //aou can modify it as you wish
            odp.value(this, "valueOf", function(){ return string }, true);
        },
    
        UPCA: function(a,b,c,d){
            //you can also return an object with the properties you want to add
            //and Arrays, ...
            return [
                uint(a), 
                uint(b), 
                uint(c), 
                uint(d)
            ];
            //but beware, this doesn't add the Array.prototype-methods!!!
    
            //event this would work, and be processed like an Array
            return arguments;
        },
    
        Other: function(properties){ 
            return properties;  //some sugar
        }
    });
    
    var productBarcode = Barcode.UPCA(8, 85909, 51226, 3);
    console.log("productBarcode is Barcode:", productBarcode instanceof Barcode);   //true
    console.log("productBarcode is Barcode.UPCA:", productBarcode instanceof Barcode.UPCA); //true
    
    console.log("productBarcode is Barcode.Other:", productBarcode instanceof Barcode.Other);   //false
    
    console.log("accessing values: ", productBarcode[0], productBarcode[1], productBarcode[2], productBarcode[3], productBarcode.length);
    
    Array.prototype.forEach.call(productBarcode, function(value, index){
        console.log("index:", index, "  value:", value);
    });
    

    Objects and Primitives

    var indices = Enum({
        lo: { from: 0, to: 13 },
        hi: { from: 14, to: 42 },
    
        avg: 7
    });
    
    var lo = indices.lo;
    console.log("lo is a valid index", lo instanceof indices);
    console.log("lo is indices.lo", lo === indices.lo); 
    //indices.lo always references the same Object
    //no function-call, no getter!
    
    var avg = indices.avg;  //beware, this is no primitive, it is wrapped
    
    console.log("avg is a valid index", avg instanceof indices);
    console.log("comparison against primitives:");
    console.log(" - typesafe", avg === 7);  //false, since avg is wrapped!!!
    console.log(" - loose", avg == 7);  //true
    console.log(" - typecast+typesafe", Number(avg) === 7); //true
    
    //possible usage like it was a primitive.
    for(var i=lo.from; i<lo.to; ++i){
        console.log(i, i == avg);   //take a look at the first output ;)
    }
    
    //but if you want to use some of the prototype methods 
    //(like the correct toString()-method on Numbers, or substr on Strings)
    //make sure that you have a proper primitive!
    
    var out = avg.toFixed(3);
    //will fail since this object doesn't provide the prototype-methods of Number
    
    //+avg does the same as Number(avg)
    var out = (+avg).toFixed(3);    //will succeed
    

    Identity

    var def = { foo: 42 };
    
    var obj = Enum({
        a: 13,
        b: 13,
        c: 13,
    
        obj1: def,
        obj2: def
    });
    
    //although all three have/represent the same value, they ain't the same
    var v = obj.a;
    console.log("testing a", v === obj.a, v === obj.b, v===obj.c);  //true, false, false
    
    var v = obj.b;
    console.log("testing a", v === obj.a, v === obj.b, v===obj.c);  //false, true, false
    
    var v = obj.c;
    console.log("testing a", v === obj.a, v === obj.b, v===obj.c);  //false, false, true
    
    
    console.log("comparing objects", obj.obj1 === obj.obj2);    //false
    console.log("comparing property foo", obj.obj1.foo === obj.obj2.foo);   //true
    
    //same for the values provided by the factory-functions:
    console.log("compare two calls with the same args:");
    console.log("Barcode.Other() === Barcode.Other()", Barcode.Other() === Barcode.Other());
    //will fail, since the factory doesn't cache, 
    //every call creates a new Object instance.
    //if you need to check wether they are equal, write a function that does that.
    

    extendProto

    //your chance to extend the prototype of each subordinated entry in the enum
    //maybe you want to add some method from some other prototype 
    //like String.prototype or iterator-methods, or a method for equality-checking, ...
    
    var Barcode = Enum("Barcode", {/* factories */}, function(proto, key, value){
        var _barcode = this;    
        //so you can access the enum in closures, without the need for a "global" variable.
        //but if you mess around with this, you are the one to debug the Errors you produce.
    
        //this function is executed right after the prototpe-object for this enum-entry is created
        //and before any further modification.
        //neither this particular entry, nor the enum itself are done yet, so don't mess around with them.
    
        //the only purpose of this method is to provide you a hook 
        //to add further properties to the proto-object
    
        //aou can also return an object with properties to add to the proto-object.
        //these properties will be added as configurable and writable but not enumerable.
        //and no getter or setter. If you need more control, feel free to modify proto on you own.
        return {
            isBarcode: function(){
                return this instanceof _barcode;
            }
        }
    });
    
    //OR you can define it for every single property, 
    //so you don't have to switch on the different properties in one huge function
    var Barcode = Enum("Barcode", {/* factories */}, {
        "UPCA": function(proto, key, value){
            //same behaviour as the universal function
            //but will be executed only for the proto of UPCA
    
            var _barcode = this;    //aka Barcode in this case
            var AP = [];
            return { 
                //copy map and indexOf from the Array prototype
                map: AP.map,
                indexOf: AP.indexOf, 
    
                //and add a custom toString and clone-method to the prototype
                toString: function(){
                    return "UPCA[ "+AP.join.call(this, ", ")+" ]";
                },
                clone: function(){
                    return _barcode.UPCA.apply(null, this);
                } 
            };
        },
    
        //OR
        "QRCode": {
            //or simply define an object that contains the properties/methods 
            //that should be added to the proto of QRCode
            //again configurable and writable but not enumerable
    
            substr: String.prototype.substr,
            substring: String.prototype.substring,
            charAt: String.prototype.charAt,
            charCodeAt: String.prototype.charCodeAt
        }
    });
    //mixin-functions and objects can be mixed