What can the JavaScript prototype system do beyond mimicking a classical class system?

The prototype system offers a captivating model of metaprogramming, by implementing inheritance via standard objects. Of course, this is mostly used to express the established and simple concept of classes of instances, but without classes as language-level immutable structures that need specific syntax to create them. By using plain objects, all you can do to objects (and you can do everything) you can now do to "classes" - this is the flexibility you talk of.

This flexibility is then used a lot to extend and alter classes programmatically, using only the given object-mutation capabilities of JavaScript:

  • mixins and traits for multiple inheritance
  • prototypes can be modified after objects that inherit from them have been instantiated
  • higher-order functions and method decorators can be used easily in the creation of prototypes

Of course, the prototype model itself is more powerful than to just implement classes. These features are used rather seldom, as the class concept is very useful and widespread, so the actual powers of prototype inheritance are not well-known (and not well-optimised in JS engines :-/)

  • switching out prototypes of existing objects can be used to alter their behaviour dramatically. (full support coming with ES6 Reflect.setPrototypeOf)

  • a few software engineering patterns can be implemented directly with objects. Examples are the flyweight pattern with properties, a chain of responsibilities including dynamic chains, oh, and of course the prototype pattern.

    A good example for the last one would be option objects with defaults. Everyone creates them using

      var myOptions = extend({}, defaultOptions, optionArgument);
    

    but a more dynamic approach would be to use

      var myOptions = extend(Object.create(defaultOptions), optionArgument);
    

Back in June 2013 I answered a question on the benefits of prototypal inheritance over classical. Since then, I spent a lot of time pondering over inheritance, both prototypal and classical and I wrote extensively about the prototype-class isomorphism.

Yes, the primary use of prototypal inheritance is to simulate classes. However, it can be used for a lot more than to just simulate classes. For example, prototype chains are very similar to scope chains.

Prototype-Scope Isomorphism as well

Prototypes and scopes in JavaScript have a lot in common. There are three common types of chains in JavaScript:

  1. Prototype chains.

    var foo = {};
    var bar = Object.create(foo);
    var baz = Object.create(bar);
    
    // chain: baz -> bar -> foo -> Object.prototype -> null
    
  2. Scope chains.

    function foo() {
        function bar() {
            function baz() {
                // chain: baz -> bar -> foo -> global
            }
        }
    }
    
  3. Method chains.

    var chain = {
        foo: function () {
            return this;
        },
        bar: function () {
            return this;
        },
        baz: function () {
            return this;
        }
    };
    
    chain.foo().bar().baz();
    

Out of the three, prototype chains and scope chains are the most similar. In fact, you can attach a prototype chain to a scope chain using the notorious with statement.

function foo() {
    var bar = {};
    var baz = Object.create(bar);

    with (baz) {
        // chain: baz -> bar -> Object.prototype -> foo -> global
    }
}

So what's the use of the prototype-scope isomorphism? One direct use is to model scopes chains using prototype chains. This is exactly what I did for my own programming language Bianca, which I implemented in JavaScript.

I first defined the global scope of Bianca, populating it with a bunch of useful math functions in a file aptly named global.js as follows:

var global = module.exports = Object.create(null);

global.abs   = new Native(Math.abs);
global.acos  = new Native(Math.acos);
global.asin  = new Native(Math.asin);
global.atan  = new Native(Math.atan);
global.ceil  = new Native(Math.ceil);
global.cos   = new Native(Math.cos);
global.exp   = new Native(Math.exp);
global.floor = new Native(Math.floor);
global.log   = new Native(Math.log);
global.max   = new Native(Math.max);
global.min   = new Native(Math.min);
global.pow   = new Native(Math.pow);
global.round = new Native(Math.round);
global.sin   = new Native(Math.sin);
global.sqrt  = new Native(Math.sqrt);
global.tan   = new Native(Math.tan);

global.max.rest = { type: "number" };
global.min.rest = { type: "number" };

global.sizeof = {
    result: { type: "number" },
    type: "function",
    funct: sizeof,
    params: [{
        type: "array",
        dimensions: []
    }]
};

function Native(funct) {
    this.funct = funct;
    this.type = "function";
    var length = funct.length;
    var params = this.params = [];
    this.result = { type: "number" };
    while (length--) params.push({ type: "number" });
}

function sizeof(array) {
    return array.length;
}

Note that I created the global scope using Object.create(null). I did this because the global scope doesn't have any parent scope.

After that, for each program I created a separate program scope which holds the top-level definitions of the program. The code is stored in a file named analyzer.js which is too big to fit into one answer. Here are the first three lines of the file:

var parse = require("./ast");
var global = require("./global");
var program = Object.create(global);

As you can see, the global scope is the parent of the program scope. Hence, program inherits from global, making scope variable lookup as simple as an object property lookup. This makes the runtime of the language much simpler.

The program scope contains the top-level definitions of the program. For example, consider the following matrix multiplication program which is stored in the matrix.bianca file:

col(a[3][3], b[3][3], i, j)
    if (j >= 3) a
    a[i][j] += b[i][j]
    col(a, b, i, j + 1)

row(a[3][3], b[3][3], i)
    if (i >= 3) a
    a = col(a, b, i, 0)
    row(a, b, i + 1)

add(a[3][3], b[3][3])
    row(a, b, 0)

The top-level definitions are col, row and add. Each of these functions has it's own function scope as well which inherits from the program scope. The code for that can be found on line 67 of analyzer.js:

scope = Object.create(program);

For example, the function scope of add has the definitions for the matrices a and b.

Hence, beside classes prototypes are also useful for modeling function scopes.

Prototypes for modeling algebraic data types

Classes are not the only type of abstraction available. In functional programming languages data is modeled using algebraic data types.

The best example of an algebraic data type is that of a list:

data List a = Nil | Cons a (List a)

This data definition simply means that a list of a's may either be an empty list (i.e. Nil) or else a value of type “a” inserted into a list of a's (i.e. Cons a (List a)). For example, the following are all lists:

Nil                          :: List a
Cons 1 Nil                   :: List Number
Cons 1 (Cons 2 Nil)          :: List Number
Cons 1 (Cons 2 (Cons 3 Nil)) :: List Number

The type variable a in the data definition enables parametric polymorphism (i.e. it allows the list to hold any type of value). For example, Nil could be specialized to a list of numbers or a list of booleans because it has the type List a where a could be anything.

This allows us to create parametric functions like length:

length :: List a -> Number
length Nil        = 0
length (Cons _ l) = 1 + length l

The length function could be used to find the length of any list irrespective of the type of values it contains because the length function simply doesn't care about the values of the list.

In addition to parametric polymorphism most functional programming languages also have some form of ad-hoc polymorphism. In ad-hoc polymorphism, one specific implementation of a function is chosen depending upon the type of a polymorphic variable.

For example, the + operator in JavaScript is used for both addition and string concatenation depending upon the type of the argument. This is a form of ad-hoc polymorphism.

Similarly, in functional programming languages the map function is usually overloaded. For example, you may have a different implementation of map for lists, a different implementation for sets, etc. Type classes are one way to implement ad-hoc polymorphism. For example, the Functor type class provides the map function:

class Functor f where
    map :: (a -> b) -> f a -> f b

We then create specific instances of Functor for different data types:

instance Functor List where
    map :: (a -> b) -> List a -> List b
    map _ Nil        = Nil
    map f (Cons a l) = Cons (f a) (map f l)

Prototypes in JavaScript allow us to model both algebraic data types and ad-hoc polymorphism. For example, the above code can be translated one-to-one to JavaScript as follows:

var list = Cons(1, Cons(2, Cons(3, Nil)));

alert("length: " + length(list));

function square(n) {
    return n * n;
}

var result = list.map(square);

alert(JSON.stringify(result, null, 4));
<script>
// data List a = Nil | Cons a (List a)

function List(constructor) {
    Object.defineProperty(this, "constructor", {
        value: constructor || this
    });
}

var Nil = new List;

function Cons(head, tail) {
    var cons  = new List(Cons);
    cons.head = head;
    cons.tail = tail;
    return cons;
}

// parametric polymorphism

function length(a) {
    switch (a.constructor) {
    case Nil:  return 0;
    case Cons: return 1 + length(a.tail);
    }
}

// ad-hoc polymorphism

List.prototype.map = function (f) {
    switch (this.constructor) {
    case Nil:  return Nil;
    case Cons: return Cons(f(this.head), this.tail.map(f));
    }
};
</script>

Although classes can be used to model ad-hoc polymorphism as well, all the overloaded functions need to defined in one place. With prototypes, you can define them wherever you want.

Conclusion

As you can see, prototypes are very versatile. Yes, they are primarily used to model classes. However, they can be used for so many other things.

Some of the other things that prototypes can be used for:

  1. Creating persistent data structures with structural sharing.

    • Understanding Clojure's Persistent Vectors, pt. 1
    • Understanding Clojure's Persistent Vectors, pt. 2
    • Understanding Clojure's Persistent Vectors, pt. 3

    The basic idea of structural sharing is that instead of modifying an object, create a new object which inherits from the original object and make any modifications you want. Prototypal inheritance excels at that.

  2. As others have mentioned, prototypes are dynamic. Hence, you can retroactively add new prototype methods and they will be automatically available on all the instances of the prototype.

Hope this helps.