Array slices and membranes using ES6 proxies

comment

JavaScript’s dynamic nature, first-class functions and prototypes could always be used for elegant metaprogramming. Proxies let programmers further blur the line between the native, user defined objects and the host objects.

Some of the examples below require native implementation of either harmony:proxies (obsolete standard, Chrome 37, using a polyfill) or harmony:direct_proxies (current standard, Firefox 31). In Chrome it is necessary to enable Experimental JavaScript at chrome://flags/#enable-javascript-harmony.

Mimicking an array

One interesting example of the difference between built-in and user-defined objects is the emulation of a built-in object (in this case an array) by the user-defined object.

The code below treats the array as a queue, adding elements at the end of this structure and removing them from the beginning.

var arr = [17, 29, 51];

arr.push(99);

print('Length: ' + arr.length);
print(arr);

print('First element: ' + arr.shift());
print(arr);

    Because functions in the Array’s prototype are generic they work on any array-like object.

    var obj = { 0: 17, 1: 29, 2: 51, length: 3 };
    
    // borrow push and shift functions from arrays
    obj.push = [].push;
    obj.shift = [].shift;
    
    obj.push(99);
    
    print('Length: ' + obj.length);
    print(obj);
    
    print('First element: ' + obj.shift());
    print(obj);

      But the length property is special — writing to it shortens or expands the array.

      var arr = [17, 29, 51];
      
      arr.length = 1;
      
      print('Length: ' + arr.length);
      print(arr);

        The same does not work on plain objects — the length value is updated but the numbered properties are not deleted.

        var obj = { 0: 17, 1: 29, 2: 51, length: 3 };
        
        obj.length = 1;
        
        print('Length: ' + obj.length);
        print(obj);

          The reason is that when the length property is modified on an array instance a built-in function (the setter) updates other properties.

          Getters and setters in ES5

          One of the features of ECMAScript 5 is the ability to add getters and setters to the user-defined objects. These execute user-provided functions when the property is accessed or set hiding the fact whether the property is computed or not.

          The following code defines both a getter and a setter for the length property. The setter implements an algorithm similar to the one used in Array’s length — removing elements with keys higher or equal to given length. The explicit, initial value of length is no longer needed as it is always computed by the getter.

          To run the example your browser has to support:

          • Getters and setters
          var obj = {
              0: 17,
              1: 29,
              2: 51,
              push: [].push,
              shift: [].shift,
              get length() {
                  function isNumber(n) {
                      return !isNaN(parseInt(n, 10)) && isFinite(n);
                  }
                  var numericKeys = Object.getOwnPropertyNames(this).
                      filter(isNumber).map(Number);
                  if (typeof this._length === 'number') {
                      numericKeys.push(this._length - 1);
                  }
                  return Math.max.apply(null, numericKeys) + 1;
              },
              set length(value) {
                  var length = this.length;
                  for (var i = value; i < length; i++) {
                      delete this[i];
                  }
                  if (value > length) {
                      this._length = value;
                  } else {
                      delete this._length;
                  }
              }
          };
          print('Length: ' + obj.length);
          print(obj);
          
          obj.length = 1;
          obj.push(42);
          
          print('Length: ' + obj.length);
          print(obj);

            The complexity of the length getter stems from the fact that the numbered properties can be set at any time and that should also be reflected by the value of length.

            • Getters and setters
            var arr = [1, 2, 3];
            arr[10] = 10;
            
            print('Length of the array: ' + arr.length);
            
            obj[10] = 10;
            
            print('Length of the object: ' + obj.length);

              Altering object’s prototype in ES6

              ECMAScript 6 brings several new functions that can be used to change the behavior of an object after it is constructed.

              The Object.setPrototypeOf function allows modifying the prototype chain by altering the internal [[Prototype]] property of objects.

              To run the example your browser has to support:

              • ES6 setPrototypeOf
              var obj = { 0: 17, 1: 29, 2: 51, length: 3 };
              
              Object.setPrototypeOf(obj, Array.prototype);
              
              obj.push(44);
              
              print(obj);
              print('Length: ' + obj.length);

                The prototype chain works and all array methods are accessible from the custom object but unfortunately setting the element at a high index does not modify the length property as it does for arrays. This behavior is unique to built-in array objects and cannot be emulated by the modifications to the prototype alone.

                • ES6 setPrototypeOf
                var obj = { 0: 17, 1: 29, 2: 51, length: 3 };
                
                Object.setPrototypeOf(obj, Array.prototype);
                
                obj[10] = 10;
                
                print(obj);
                print('Length: ' + obj.length);

                  Proxies

                  Another approach to this problem involves using the proxy object. This proxy object has special methods called traps that are invoked when a property is being read or written to. It is similar to how getters and setters work but the property name does not have to be known in advance as the traps are invoked on each property access.

                  To run the example your browser has to support:

                  • Proxies
                  function emulateArray(obj) {
                      var length = obj.length || 0;
                      return new Proxy(obj, {
                          get: function(target, property) {
                              if (property === 'length') {
                                  return length;
                              }
                              if (property in target) {
                                  return target[property];
                              }
                              if (property in Array.prototype) {
                                  return Array.prototype[property];
                              }
                          },
                          set: function(target, property, value) {
                              if (property === 'length') {
                                  for (var i = value; i < length; i++) {
                                      delete target[i];
                                  }
                                  length = value;
                                  return;
                              }
                              target[property] = value;
                              if (Number(property) >= length) {
                                  length = Number(property) + 1;
                              }
                          }
                      });
                  }
                  
                  var obj = {
                      0: 17,
                      1: 29,
                      2: 51,
                      length: 3
                  };
                  
                  obj = emulateArray(obj);
                  
                  print('Length: ' + obj.length);
                  print(obj);
                  
                  obj.length = 1;
                  obj.push(42);
                  
                  print('Length: ' + obj.length);
                  print(obj);
                  print('Last element: ' + obj.pop());

                    The get trap supports reading the special length property, properties from the target object (indexed by numbers) and the functions in the Array prototype (for array methods — simulating the prototype chain). The set trap supports truncating properties and updating the internal length property.

                    Array slices

                    The same characteristic of proxies — intercepting calls to non-existent properties — can be used to implement array slices in JavaScript that looks similar to Python’s slices or even to add support to negative indices to arrays.

                    This proxy extends the property access to a given array by allowing the use of ranges and multiple indices.

                    To run the example your browser has to support:

                    • Proxies
                    var p = new Slice([1, 3, 6, 10, 15, 21, 28, 36, 45, 55]);
                    
                    // get properties 0 to 4 then 6 and 7
                    var slice = p['0..4,6,7'];
                    
                    print(slice);
                    
                    // overwrite properties from 0 to 4 with values from 5 to 10
                    p['0..4'] = p['5..10'];
                    
                    print(p);

                      The implementation uses simple notation to indicate ranges (start..end) and groups of indices (x,y,z).

                      All properties that are numeric or start with a symbol or a letter are forwarded to the original array (the target).

                      Additionally the toJSON instance function is supported so that the proxy object can be serialized like a regular array.

                      var Slice = (function() {
                          var parts = new Map();
                      
                          parts.set(/^\d+$/, function(matches) {
                              return [ matches[0] ];
                          });
                      
                          parts.set(/^([0-9]+)\.\.([0-9]+)$/, function(matches) {
                              var lower = +matches[1];
                              var upper = +matches[2];
                              var indices = [];
                              for (var i = lower; i <= upper; i++) {
                                  indices.push(i);
                              }
                              return indices;
                          });
                      
                          function getIndices(spec) {
                              var indices = spec.split(',').map(function(part) {
                                  var indices = [];
                                  parts.forEach(function(value, regexp) {
                                      var matches = part.match(regexp);
                                      if (matches) {
                                          indices.push.apply(indices, value(matches));
                                      }
                                  });
                                  if (indices.length === 0) {
                                      throw new Error('Unknown spec: ' + part);
                                  }
                                  return indices;
                              });
                              return Array.prototype.concat.apply([], indices);
                          }
                      
                          var nativeArrayProperty = /^\d+$|^[^\d-]/;
                      
                          return function Slice(target) {
                              return new Proxy(target || [], {
                                  get: function(target, property) {
                                      if (property === 'toJSON') {
                                          return function() {
                                              return target;
                                          };
                                      }
                                      if (property.match(nativeArrayProperty)) {
                                          return target[property];
                                      }
                                      var indices = getIndices(property, target);
                                      return indices.map(function(index) {
                                          return target[index];
                                      });
                                  },
                                  set: function(target, property, value) {
                                      if (property.match(nativeArrayProperty)) {
                                          return target[property] = value;
                                      }
                                      var indices = getIndices(property, target);
                                      indices.forEach(function(rangeIndex, index) {
                                          target[rangeIndex] = value[index];
                                      });
                                  }
                              });
                          };
                      }());

                      Membranes

                      Membrane is a mechanism used to limit access to the entire object graph. While Object.freeze function can be used to prevent modifications of an object a membrane is used to also restrict read access to an object.

                      This pattern prevents unsafe, third-party code from holding the object reference and reading its properties later.

                      • Proxies
                      var user = {
                          profile: {
                              avatarUrl: 'avatar-1.jpg'
                          }
                      };
                      
                      function getAbsoluteAvatarPath(user) {
                          var profile = user.profile;
                      
                          var path = profile.avatarUrl;
                      
                          setTimeout(function() {
                              try {
                                  print(profile.avatarUrl);
                              } catch (e) {
                                  print('Error while reading: ' + e);
                              }
                          }, 10);
                      
                          return 'https://example.com/avatars/' + path;
                      }
                      
                      var membrane = createMembrane(user);
                      
                      var url = getAbsoluteAvatarPath(membrane.target);
                      
                      print(url);
                      
                      membrane.revoke();

                        The getAbsoluteAvatarPath holds the reference to the user’s profile via the function’s scope even after it returns. After the membrane is revoked reading properties from the user object is impossible.

                        var createMembrane = (function() {
                        
                            var objectPool = new WeakMap();
                        
                            function Membrane(target, controller) {
                                function checkAccess() {
                                    if (controller.revoked) {
                                        throw new Error('Access has been revoked.');
                                    }
                                }
                                return new Proxy(target || [], {
                                    get: function(target, property) {
                                        checkAccess();
                                        var value = target[property], membrane;
                                        if (typeof value === 'object') {
                                            membrane = objectPool.get(value);
                                            if (!membrane) {
                                                membrane = new Membrane(value, controller);
                                                objectPool.set(value, membrane);
                                            }
                                            return membrane;
                                        }
                                        return value;
                                    },
                                    set: function(target, property, value) {
                                        checkAccess();
                                        target[property] = value;
                                    },
                                    delete: function(target, property) {
                                        checkAccess();
                                        delete target[property];
                                    },
                                    getOwnPropertyDescriptor: function(property) {
                                        checkAccess();
                                        return Object.getOwnPropertyDescriptor(target, property);
                                    },
                                    getPropertyDescriptor:  function(property) {
                                        checkAccess();
                                        return Object.getPropertyDescriptor(target, property);
                                    },
                                    getOwnPropertyNames: function() {
                                        checkAccess();
                                        return Object.getOwnPropertyNames(target);
                                    },
                                    getPropertyNames: function() {
                                        checkAccess();
                                        return Object.getPropertyNames(target);
                                    },
                                    defineProperty: function(property, desc) {
                                        checkAccess();
                                        Object.defineProperty(target, property, desc);
                                    },
                                    has: function(property) {
                                        checkAccess();
                                        return property in target;
                                    },
                                    hasOwn: function(property) {
                                        checkAccess();
                                        return ({}).hasOwnProperty.call(target, property);
                                    },
                                    enumerate: function() {
                                        checkAccess();
                                        var result = [];
                                        for (var name in target) {
                                            result.push(name);
                                        };
                                        return result;
                                    },
                                    keys: function() {
                                        checkAccess();
                                        return Object.keys(target);
                                    }
                                });
                            }
                        
                            return function(target) {
                                var controller = {
                                    revoked: false
                                };
                                return {
                                    target: new Membrane(target, controller),
                                    revoke: function() {
                                        controller.revoked = true;
                                    }
                                };
                            };
                        }());

                        The implementation checks if the access has been revoked in all traps and then forwards the operation to the real object (the target).

                        The get trap additionally checks if the value retrieved is an object and if so wraps that inner object in a membrane too to secure the access to the entire object graph. A WeakMap object is needed so that for each original object the same membrane always is returned. That keeps the condition user.profile === user.profile true if the user variable is an object wrapped in the membrane.

                        Calling remote services

                        Another use of proxies is to provide straightforward access to remote resources when the exact names of remote resources is not known in advance.

                        The code below provides programmatic access to annotations for articles.

                        To run the example your browser has to support:

                        • Proxies
                        • Promises
                        var api = new Proxy({ key: null }, {
                            get: function(target, property) {
                                if (property in target) {
                                    return target[property];
                                }
                                function getAnnotations() {
                                    var auth = 'Basic ' + btoa(target.key + ':');
                                    var url = property.replace(/([a-z])([A-Z])/g, '$1-$2').
                                        toLowerCase() + '/annotations';
                                    var success, error;
                                    var xhr = new XMLHttpRequest();
                                    xhr.onload = function() {
                                        if (xhr.status === 200) {
                                            success(JSON.parse(xhr.responseText));
                                        } else {
                                            error(new Error('Status: ' + xhr.status));
                                        }
                                    };
                                    xhr.onerror = error;
                                    xhr.open('GET', url);
                                    xhr.setRequestHeader('Authorization', auth);
                                    xhr.send();
                                    return new Promise(function(resolve, reject) {
                                        success = resolve;
                                        error = reject;
                                    });
                                }
                                return {
                                    getAnnotations: getAnnotations
                                };
                            }
                        });
                        
                        api.key = localStorage['curiosity-driven.annotations.username'];
                        
                        api.arraySlices.getAnnotations().then(function(annotations) {
                            print('Annotations: ');
                            print(annotations);
                        }, function(e) {
                            print('Error: ' + e);
                        });

                          Change arraySlices to bitcoinContracts in the code above to see annotations for a different article.

                          Proxies can also be used to create objects that throw exceptions when an undefined property is accessed or limit access to functions.

                          Comments

                          cancel

                          Revisions

                          1. Initial version.