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 = OMGOperator 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
endMetaprogramming 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.preventExtensionsMonkey 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 ()Proxies
The Proxy object is used to define custom behavior for fundamental operations
(e.g. property lookup, assignment, enumeration, function invocation, etc).
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;
}
}
});
}source: Nicholas C. Zakas blog
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 errorsource: Nicholas C. Zakas blog
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]); // cPartial 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.trueWeb 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
// 42Wrap Up
- Metaprogramming is {fun, hard, dangerous}
- Help solve duplication problems
- Proxies are almost here...
- ES2015 is a language with great metaprogramming features
References
- Brendan Eich, Proxies are awesome
- Dr. Axel Rauschmayer, Meta programming with ECMAScript 6 proxies
- T. Van Cutsem et al, Proxies: Design Principles for Robust Object-oriented Intercession APIs
- Keith Cirkel, Metaprogramming in ES6: Symbols and why they're awesome
- Paolo Perrota, Metaprogramming Ruby 2
- Chris Mc Cord, Metaprogramming Elixir
- https://github.com/tvcutsem/harmony-reflect
Questions ?
Metaprogramming in JS with Proxies
By Thomas BRISBOUT
Metaprogramming in JS with Proxies
- 2,394