Metaprogramming in JS with Proxies

Thomas Brisbout

Freelance JS developer

 tbrisbout

Node.js Paris Meetup / 2016-01-19

what is metaprogramming ?

 Levels

  • Base level => Program processes User Input
  • Meta level => Program processes program

code that writes code...

code that manipulates language constructs at runtime

Metaprogramming Ruby

 Domains

  • Code compilers / transpilers
  • Domain Specific Languages
  • Code analysis & coverage tools

Lisp macros

(defmacro triple (x)
    '(+ ~x ~x ~x))

;; every time triple is encountered in lisp code:
(triple 4)

;; it is replaced with the following code:
(+ 4 4 4)

C++ Templates

template<class TYPE>
void PrintTwice(TYPE data)
{
    cout<<"Twice: " << data * 2 << endl;
}

PrintTwice(124);
PrintTwice(4.5547);

// Compiler creates implementation
void PrintTwice(int data) { ... }
void PrintTwice(double data) { ... }

Java reflection API

// PrivateStuff.java
public class PrivateStuff {
  private String foo = null;

  public PrivateStuff(String foo) {
    this.foo = foo;
  }
}

// In Main
PrivateStuff ps = new PrivateStuff("OMG");

Field field = PrivateStuff.class.getDeclaredField("foo");
field.setAccessible(true);

System.out.println("fieldValue = " + field.get(ps));
// -> fieldValue = OMG

Operator overloading in Ruby

class Point
    attr_accessor :x, :y

    def initialize(x,y)
        @x, @y = x, y
    end

    def +(other)
      Point.new(@x + other.x, @y + other.y)
    end

    def to_s
        "(#{@x}, #{@y})"
    end
end

p1 = Point.new 1, 2
p2 = Point.new 3, 1
p3 = p1 + p2
puts p3.to_s # => (4, 3)

DSL with Elixir macros

markup do
  div class: "row" do
    article do
      p do: text "Welcome !"
    end
  end
end

Metaprogramming Elixir

eval

eval('var x = 1; console.log(x)')

Why going meta ?

Don't Repeat Yourself

API Call file

// some code

'use strict';

module.exports = function() {
  var promisesRunning = {};
  var requestsErrorQueue = [];
  var tryingToReconnect = false;

  var FooService = { apiCall: function(options) {
     // actual http call
    },
    getMember: function(aboid) {
      return FooService.apiCall({
        url: '/members/' + aboid,
        cache: true,
        params: {with_format_picture: ApiConfig.pictures_formats}
      });
    },
    getMembers: function(requestParameters) {
      var params = _.merge({
      }, requestParameters);

      return FooService.apiCall({
        url: '/members',
        params: params
      });
    },
    putMember: function(id, data) {
      return FooService.apiCall({
        url: '/members/' + id,
        method: 'PUT',
        data: {
          members: data
        }
      });
    },
    getConfigStuff: function(id) {
      return FooService.apiCall({
        url: '/configurations/stuff/' + id
      });
    },
    getConfigurations: function() {
      return FooService.apiCall({
        url: '/configurations'
    },
    getUser: function(id) {
      return q.all({
        account: FooService.apiCall({
          url: '/users/' + id
        }),
        member: FooService.apiCall({
          url: '/people/' + id
        })
      });
    },
    getLikes: function() {
      return FooService.apiCall({
        url: '/likes'
      });
    },
    getThreads: function(page) {
      return FooService.apiCall({
        url: '/inbox/threads',
        params: {
          page: page
        }
      });
    },
    getThread: function(id, page) {
      return FooService.apiCall({
        url: '/inbox/messages',
        params: {
          include: 'members'
        }
      });
    },
    readMessage: function(payload) {
      return FooService.apiCall({
        url: '/inbox/messages',
        method: 'PUT',
        data: payload
      });
    },
    postMessage: function(data) {
      return FooService.apiCall({
        url: '/inbox/messages',
        method: 'POST',
        data: data
      });
    },
    sendActivities: function(payload) {
      return FooService.apiCall({
        method: 'POST',
        url: '/interactions',
        data: payload
      });
    },
    sendActivities: function(payload) {
      return FooService.apiCall({
        method: 'POST',
        url: '/interactions',
        data: payload
      });
    },
    getCountries: function(params) {
      return FooService.apiCall({
        url: '/geo/countries',
        params: params
      });
    },
    postPicture: function(data) {
      return FooService.apiCall({
        url: '/pictures',
        method: 'POST',
        headers: {'Content-Type': undefined},
        data: data
      });
    },
    
    deletePicture: function(pictureId) {
      return FooService.apiCall({
        url: '/pictures/' + pictureId,
        method: 'DELETE'
      });
    },
    getUserPictures: function(format) {
      return FooService.apiCall({
        url: '/pictures',
        method: 'GET',
      });
    },
    getPaymentUrl: function(params) {
      return FooService.apiCall({
        url: '/payment/url',
        method: 'GET',
        params: params
      });
    },
    getSpecialOfferUrl: function(id, params) {
      return FooService.apiCall({
        url: '/payment/special_offers/' + id,
        method: 'GET',
        params: params
      });
    },
    get: function(status) {
      return FooService.apiCall({
        url: '/boosts',
        method: 'GET',
        params: {
          status: status
        }
      });
    },
    getSearches: function() {
      return FooService.apiCall({
        url: '/searches',
        method: 'GET'
      });
    },
    postSearches: function(data) {
      return FooService.apiCall({
        url: '/searches',
        method: 'POST',
        data: data
      });
    },
    putAccount: function(data) {
      return FooService.apiCall({
        url: '/accounts/',
        method: 'PUT',
        data: {
          accounts: data
        }
      });
    },
    getMeetups: function() {
      return FooService.apiCall({
        url: '/meetups',
        method: 'GET'
      });
    },
    getMeetup: function(id) {
      return FooService.apiCall({
        url: '/meetups/' + id,
        method: 'GET'
      });
    },
    getGeoPlaces: function(data) {
      return FooService.apiCall({
        url: '/geo/places',
        method: 'GET',
        params: data
      });
    },
    regMeetup: function(id, oid, data) {
      return FooService.apiCall({
        url: '/meetup/' + id + '/reg/' + oid,
        method: 'POST',
        data: data
      });
    },
    getFaq: function(data) {
      return FooService.apiCall({
        url:'/faq',
        method: 'GET',
        params: data
      });
    },
    getDiscount: function() {
      return FooService.apiCall({
        url:'/discounts',
        method: 'GET'
      });
    },
    getEventPaymentUrl: function(data) {
      return FooService.apiCall({
        url:'/payment/stuff',
        method: 'POST',
        data: data
      });
    }
  };
}

Mongoose fat model

var mongoose = require('mongoose');
var Schema = mongoose.Schema;

var userSchema = new Schema({
  name:  String,
  lastname: String,
  nickname:   String,
  city: String,
  history: [{ body: String, date: Date }],
  inscriptionDate: { type: Date, default: Date.now },
  hidden: Boolean,
  meta: {
    votes: Number,
    favs:  Number
  },
  profileInfo: [{
    birth: Date,
    height: Number,
    weight Number,
    education: [{
      lastDegree: String,
      lastDegreeDate: String,
      college: String,
      highSchool: String
    }],
    fav_movie: [{...}],
    fav_books: [{...}]  
});

var User = mongoose.model('User', userSchema);
User.statics.findByName = function (name, cb) {
  return this.find({ name: new RegExp(name, 'i') }, cb);
}

User.statics.findByCity = function (city, cb) {
  return this.find({ city: new RegExp(name, 'i') }, cb);
}

User.statics.findByCollege = function (college, cb) {
  return this.find({ profileInfo: { college : new RegExp(name, 'i') }}, cb);
}

// ...

Kinds of metaprogramming

Main categories

  • Code Generation
  • Reflection
    • Introspection
    • Self modification
    • Intercession

Object API

foo in obj

Object.keys()

Object.prototype.hasOwnProperty()

Object.prototype.getOwnProperty()

Reflect API

Reflect.apply
Reflect.deleteProperty
Reflect.getOwnPropertyDescriptor
Reflect.getPrototypeOf
Reflect.setPrototypeOf
Reflect.preventExtensions

Monkey Patching

let oldReverse = Array.prototype.reverse

Array.prototype.reverse = function() {
  oldReverse.call([].concat(this))
}

Symbol

Symbol are unique

assert.notEqual(Symbol(), Symbol());  
assert.notEqual(Symbol('foo'), Symbol('foo'));  
assert.notEqual(Symbol('foo'), Symbol('bar'));

var foo1 = Symbol('foo');  
var foo2 = Symbol('foo');  
var object = {  
    [foo1]: 1,
    [foo2]: 2,
};
assert(object[foo1] === 1);  
assert(object[foo2] === 2);

// except with Symbol.for()
assert.equal(Symbol.for('foo'), Symbol.for('foo'));

Symbol usage

log.levels = {  
    DEBUG: Symbol('debug'),
    INFO: Symbol('info'),
    WARN: Symbol('warn'),
};
log(log.levels.DEBUG, 'debug message');  
log(log.levels.INFO, 'info message');  

Well known Symbols

Symbol.iterator // used by for .. of
Symbol.match // used by String.prototype.match()
Symbol.hasInstance // used by instanceof
Symbol.isConcatSpreadable 
  // used by Array.prototype.concat()
...
Symbol.toPrimitive // used by type coercion

Symbol.toPrimitive

// An object with Symbol.toPrimitive property.
let obj = {
  [Symbol.toPrimitive](hint) {
    if (hint == "number") {
      return 10
    }
    if (hint == "string") {
      return "hello"
    }
    return true
  }
};
console.log(+obj)      // 10      -- hint is "number"
console.log(`${obj}`)  // "hello" -- hint is "string"
console.log(obj + "")  // "true"  -- hint is "default"

Intercession

Goal: Dynamic methods

// Say we have a Users collection
let userDb = new Db(/* db connection */)

userDb.findAllUnder18();

userDb.findAllUnder22();

userDb.findAllAfter60();

Ruby's method_missing

class Lawyer
  def method_missing(method, *args)
    puts "You called: #{method}(#{args.join(', ')})"
    puts "(You also passed it a block)" if block_given?
  end
end
bob = Lawyer.new
bob.talk_simple('a', 'b') do
  # a block
end

# => You called: talk_simple(a, b)
# => (You also passed it a block)

Metaprogramming Ruby

Firefox noSuchMethod

var o = {
  __noSuchMethod__: function(id, args) { 
    console.log(id, '(' + args.join(', ') + ')'); 
  }
};

o.foo(1, 2, 3);
o.bar(4, 5);
o.baz();

// Output
// foo (1, 2, 3)
// bar (4, 5)
// baz ()

MDN

Proxies

The Proxy object is used to define custom behavior for fundamental operations

(e.g. property lookup, assignment, enumeration, function invocation, etc).

MDN

Target Object

Proxy + handler

Object operations (get, set, invoke...)

Proxy API

let target = {}
let handler = {}

let p = new Proxy(target, handler)

Proxy API

let target = { foo: 1 }

let handler = {
  get(target, property, receiver) {
    return 42
  }
}

let p = new Proxy(target, handler)

console.log(target.foo) // => 1
console.log(p.foo) // => 42

Traps

handler.getPrototypeOf()
handler.setPrototypeOf()
handler.isExtensible()
handler.preventExtensions()
handler.getOwnPropertyDescriptor()
handler.defineProperty()
handler.has()
handler.get()
handler.set()
handler.deleteProperty()
handler.enumerate()
handler.ownKeys()
handler.apply()
handler.construct()

API Design Goals

  • Stratification: traps are defined on a separate handler.
  • Selective interception: some operations are not trapped or their semantics is determined at proxy-creation time.
  • Meta-level shifting: handlers as proxies shift meta-levels.
  • Temporary intercession: proxies can become fixed.
  • Handler encapsulation: a proxy encapsulates its handler.
  • Uniform intercession: every value can be proxified.

AST Comparison

--- AST ---
FUNC at 0
. NAME ""
. INFERRED NAME ""
. DECLS
. . VAR (mode = VAR) "target"
. BLOCK NOCOMPLETIONS at 0
. . EXPRESSION STATEMENT at -1
. . . CALL RUNTIME InitializeVarGlobal at 0
. . . . LITERAL "target"
. . . . LITERAL 0
. . . . OBJ LITERAL at 13
. . . . . literal_index = 0
. . . . . PROPERTY - CONSTANT
. . . . . . KEY at 15
. . . . . . . LITERAL "foo"
. . . . . . VALUE at 20
. . . . . . . LITERAL 1
var target = { foo: 1 };

AST Comparison

--- AST ---
FUNC at 0
. NAME ""
. INFERRED NAME ""
. DECLS
. . VAR (mode = VAR) "target"
. . VAR (mode = VAR) "p"
. BLOCK NOCOMPLETIONS at 0
. . EXPRESSION STATEMENT at -1
. . . CALL RUNTIME InitializeVarGlobal at 0
. . . . LITERAL "target"
. . . . LITERAL 0
. . . . OBJ LITERAL at 13
. . . . . literal_index = 0
. . . . . PROPERTY - CONSTANT
. . . . . . KEY at 15
. . . . . . . LITERAL "foo"
. . . . . . VALUE at 20
. . . . . . . LITERAL 1
. BLOCK NOCOMPLETIONS at 25
. . EXPRESSION STATEMENT at -1
. . . CALL RUNTIME InitializeVarGlobal at 25
. . . . LITERAL "p"
. . . . LITERAL 0
. . . . CALL NEW at 33
. . . . . VAR PROXY Slot(5) (mode = DYNAMIC_GLOBAL) "Proxy"
. . . . . VAR PROXY Slot(0) (mode = VAR) "target"
. . . . . OBJ LITERAL at 51
. . . . . . literal_index = 1
var target = { foo: 1 };
var p = new Proxy(target, {});

Use cases

Type-safe Properties

function createTypeSafeObject(object) {

  return new Proxy(object, {
    set: function(target, property, value) {
      var currentType = typeof target[property],
                  newType = typeof value;

      if (property in target && currentType !== newType) {
        throw new Error(`Prop ${property} must be a ${currentType}.`);
      } else {
        target[property] = value;
      }
    }
  });
}

Type-safe Properties (2)

let person = {
    name: "Nicholas"
};

let typeSafePerson = createTypeSafeObject(person);

typeSafePerson.name = "Mike"; // succeeds
typeSafePerson.age = 13;      // succeeds
typeSafePerson.age = "red";   // throws an error

Property Access Tracing

function tracePropAccess(obj, propKeys) {
  let propSet = new Set(...propKeys);

  return new Proxy(obj, {
    get(target, prop, receiver) {
      if (propSet.has(prop)) {
        console.log(`GET ${prop}`);
      }
      return Reflect.get(target, prop, receiver);
    },

    set(target, prop, value, receiver) {
      if (propSet.has(prop)) {
        console.log(`SET ${prop}=${value}`);
      }
      return Reflect.set(target, prop, value, receiver);
    },
  });
}

Basic profiling

const isDev = process.NODE_ENV === 'development'

let p = new Proxy(someFunction, {
  apply(...args) {
    if (isDev) var startTime = Date.now()
    
    let res = Reflect.apply(...args)
    
    if (isDev) console.log(Date.now() - startTime)
    return res;
  }
});

Negative indices

function createArray(...elements) {
  let handler = {
    get(target, prop, receiver) {
      let index = Number(prop);
      // Sloppy way of checking for negative indices
      if (index < 0) {
        prop = String(target.length + index);
      }
      return Reflect.get(target, prop, receiver);
    }
  };
  // Wrap a proxy around an array
  let target = [];
  target.push(...elements);
  return new Proxy(target, handler);
}

let arr = createArray('a', 'b', 'c');
console.log(arr[-1]); // c

Partial Mock

let testDependency = { ... }

let p = new Proxy(testDependency, {
  get(target, prop, receiver) {
    if (prop === 'isDBConnected') {
      return () => true
    }
    return Reflect.get(target, prop, receiver)
  }
}

expect(foo(testDependency)).to.be.true

Web service / ORM

function createWebService(baseUrl) {
  return new Proxy({}, {
    get(target, propKey, receiver) {
      return httpGet(baseUrl+'/'+propKey);  
    }
  })
}

// ORM concept to have dynamic methods
function createDbObject(db) {
  return new Proxy({}, {
    get(target, propKey, receiver) {
      // dbfind to implement
      return dbFind(propKey);  
    }
  })
}

Support

Compatibility

Current support is low

  • hard to polyfill in pure ES5
  • old spec / new spec situation
  • The harmony-reflect project  provides a patch for the old spec
  • Implementation is done in V8 4.10.0 (~80 commits about proxies in last 2 months)
  • Available in next Chrome Canary

Using in node

$ docker run -it node:5 /bin/bash 
# node -v 
5.4.1
# node -p process.versions.v8
4.6.85.31

Using in node

$ docker run -it node6-nightly /bin/sh
# node -v
v6.0.0-nightly20160118eee9dc7e9d
# node -p process.versions.v8 
4.7.80.32
# node
> Proxy
ReferenceError: Proxy is not defined
    at repl:1:1
    ...
> 
(To exit, press ^C again or type .exit)
> 
# node --harmony_proxies
> Proxy
{}
> Proxy.
Proxy.__defineGetter__      Proxy.__defineSetter__      Proxy.__lookupGetter__      Proxy.__lookupSetter__      Proxy.__proto__             Proxy.constructor           Proxy.hasOwnProperty
Proxy.isPrototypeOf         Proxy.propertyIsEnumerable  Proxy.toLocaleString        Proxy.toString              Proxy.valueOf               
Proxy.create                Proxy.createFunction  

Using in node

$ npm install harmony-reflect
# node --harmony-proxies
# node
> Proxy
{}
> var Reflect = require('harmony-reflect')

> Proxy
{ [Function]
  revocable: [Function],
  create: [Function: create],
  createFunction: [Function: createFunction] }
> var p = new Proxy({}, { get: () => console.log('hello') });

> p.foo
hello


What's next ?

Macros in JS ?

// Define the class macro here...
macro class {

  rule {

    $className {
        constructor $cparams $cbody
        $($mname $mparams $mbody) ...
    }

  } => {

    function $className $cparams $cbody

    $($className.prototype.$mname
      = function $mname $mparams $mbody; ) ...

  }

}

Realms draft proposal

class MyRealm extends Realm {
  indirectEval(src) {
    console.log("HELLO WORLD I AM INTERCEPTING YOUR TRANSMISSION");
    return src.replace(/foo/, "42");
  }
}
var r = new MyRealm();
var f = r.eval("eval"); // return the realm's global eval function
console.log(f("foo"));  // HELLO WORLD I AM INTERCEPTING YOUR TRANSMISSION
                        // 42

Wrap Up

  • Metaprogramming is {fun, hard, dangerous}
  • Help solve duplication problems
  • Proxies are almost here...
  • ES2015 is a language with great metaprogramming features

References

Questions ?

Metaprogramming in JS with Proxies

By Thomas BRISBOUT

Metaprogramming in JS with Proxies

  • 2,394