Metaprogramming SUPERPOWERS

via ES2015 Proxies

Nick Ribal

image/svg+xml

Front-end consultant, freelancer and a family man

    @elektronik

Meta-presentation

  • Metaprogramming

    • Definition

    • Types, by ES version
  • ES2015 Proxies
    • Availability, by environment
    • Basics
    • Further reading
  • Lots of runnable examples
  • Sources

Metaprogramming

Programming with the ability to treat programs as data. Reading, analysing or transforming itself, while running.

Metaprogramming enables expressing certain solution better, or allows greater flexibility in handling new situations without modification.

Metaprogramming types, ES versions

  • Introspection (read-only) in ES3/5: Object.keys() and others

  • Self-modification in ES3/5:

function capitalizeProps(obj, capitalizedObj = {}){
  for (const key in obj)
    capitalizedObj[key.toUpperCase()] = obj[key]
  return capitalizedObj
}

capitalizeProps({ foo: 'bar', })
// >> { FOO: 'bar' }
  • Intercession: you can redefine the semantics of some language operations - ES2015 Proxies

Availability and assumptions

Babel is awesome and gave us ES2015/6 in ES5 via transpilation and polyfills, except for Proxies.

So what Proxies do CANNOT be done in any other way!

But aren't we living in the future?

  1. Proxies enable you to intercept and customize operations performed on objects (such as getting, setting properties, invoking functions and others). They overload operators such as '.' and 'new'.

  2. Proxies are a metaprogramming feature

  3. Proxies let you do awesome things, which are otherwise impossible

So what's so special about Proxies anyway?

4. Proxies turn you into a 1337 h4x0r!

Semantics and terminology

const target = {}

const handler = {
  get(target, key, receiver){
    console.info(`getting property ${ key }`)
  }
}

const proxy = new Proxy(target, handler)
proxy.foo
// >> getting property foo

target: object to proxy

handler: proxy definition, containing operation traps

trap: method intercepting an operation on the target

Proxy to the deep web

const gateway = new Proxy({ isRegularWeb: true }, {
  set(target, key, value, receiver){
    console.info(`${ key } set to`, value)
    return target[key] = value
  }
})

// unless a trap is defined, operation is forwarded
// to the target unmodified
delete gateway.isRegularWeb
// >> true

gateway.ip = '127.0.0.1'
// >> ip set to 127.0.0.1
Proxy trap/Reflect method Operation on Object t = { foo: 'bar' }
getPrototypeOf() Object.getPrototypeOf(t)
setPrototypeOf() Object.setPrototypeOf(t, null)
isExtensible() Object.isExtensible(t)
preventExtensions() Object.preventExtensions(t)
getOwnPropertyDescriptor() Object.getOwnPropertyDescriptor(t, 'foo')
defineProperty() Object.defineProperty(t, 'bar', {value: 'baz'})
ownKeys() Object.getOwnPropertyNames(t)
has() 'foo' in t
get() t.foo
set() t.bar = 'baz'
deleteProperty() delete t.bar
apply() t.toString()
construct() new t()

Tracing property access

function tracePropAccess(obj, ...propKeys){
  const propKeySet = new Set(propKeys)
  return new Proxy(obj, {
    get(target, propKey, receiver){
      if (propKeySet.has(propKey)) {
        console.info(`GET ${ propKey }`)
      }
      return Reflect.get(...arguments)
    },
    set(target, propKey, value, receiver){
      if (propKeySet.has(propKey)) {
        console.info(`SET ${ propKey } = ${ value }`)
      }
      return Reflect.set(...arguments)
    },
  })
}
class Point {
  constructor(x = 0, y = 0, z = 0){
    Object.assign(this, { x, y, z, })
  }
  toString(){
    const { x, y, z, } = this
    return `Point[${ x }, ${ y }, ${ z }]`
  }
}

const origin = new Point()
const tracePoint = tracePropAccess(origin, 'x', 'y')

tracePoint.y = 42
// >> SET y = 42

tracePoint.toString()
// >> GET x
// >> GET y
// >> "Point[0, 42, 0]"

If a tree is proxied in a forest...

const root = {}
root.tree.branch.leaf = 'awesome'
// >> Uncaught TypeError: Cannot
// >> read property 'branch' of undefined

...is it still undefined?

Not if it's a recursive Proxy!

function Tree(obj = {}){
  return new Proxy(obj, {
    get(target, key, receiver){
      if (!(key in target)) {
        target[key] = Tree()
      }
      return Reflect.get(...arguments)
    }
  })
}

const root = Tree()
root.tree.branch.leaf = 'awesome'
// >> "awesome"

<span>undefined</span>

const render = (text) => `<span>${ text }</span>`

const api = {
  foo: 'foo',
  getFoo(){ return this.foo },
}

render(api.foo)
// >> "<span>foo</span>"
render(api.getFoo())
// >> "<span>foo</span>"

render(api.bar)
// >> "<span>undefined</span>"

render(api.getBar())
// >> Uncaught TypeError: api.getBar is not a function
function errorMaker(error){
  const toString = () => error
  const errorMessage = () => ({ error, toString, })
  errorMessage.toString = toString
  return errorMessage
}

const safeApi = new Proxy(api, {
  get(target, key, receiver){
    if (!(key in target)) {
      return errorMaker(`No '${ key }' found.`)
    }
    return Reflect.get(...arguments)
  }
})

render(safeApi.bar)
// >> "<span>No 'bar' found.</span>"
render(safeApi.getBar())
// >> "<span>No 'getBar' found.</span>"

Runtime type checking

function throwOnTypeMismatch(target, key, value){
  const currentType = typeof target[key]
  if (key in target && currentType !== typeof value) {
    throw new Error(
      `Property '${ key }' must be a ${ currentType }.`
    )
  }
}

function createTypeSafeObject(object = {}){
  return new Proxy(object, {
    set(target, key, value){
      throwOnTypeMismatch(...arguments)
      return Reflect.set(...arguments)
    }
  })
}
const person = { name: 'Sam', }
const safePerson = createTypeSafeObject(person)

safePerson.name = true
// >> Uncaught Error: Property 'name' must be a string.

safePerson.name = 'Bill'
// >> "Bill"

safePerson.age = 32
// >> 32

safePerson.age = null
// >> Uncaught Error: Property 'age' must be a number.

Useful for validation

Extensible, yet type-safe!

Negative array indices

function getPositiveKey(key, { length, }){
  const i = parseInt(key, 10)
  return (!isNaN(i) && i < 0) ? (length + i) : key
}

const arr = new Proxy(['a', 'b', 'c'], {
  get(target, key, receiver){
    const positiveKey = getPositiveKey(key, target)
    return Reflect.get(target, positiveKey, receiver)
  },
  set(target, key, value, rcver){
    const posKey = getPositiveKey(key, target)
    return Reflect.set(target, posKey, value, rcver)
  },
})

arr[-1]
// >> "c"
arr[-1] = 'd'
// >> "d"

A poor man's observables

function observe(object = {}, observers = new Set()){
  const proxy = new Proxy(object, {
    set(target, key, value, receiver){
      const oldValue = target[key]
      const forwardOpResult = Reflect.set(...arguments)
      observers.forEach(
        observer => observer({ key, oldValue, value, })
      )
      return forwardOpResult
    }
  })
  proxy.subscribe = (subscriber) => {
    observers.add(subscriber)
    return proxy
  }
  return proxy
}

...in < 20 LoC!

const info = console.info.bind(console)
function logChanges({ key, oldValue, value, }){
  info(`${ key } change from ${ oldValue } to ${ value }`)
}

const observable = observe()

observable
  .subscribe(logChanges)
  .subscribe(info)

observable.foo = 'foo'
// >> foo change from undefined to foo
// >> {key: "foo", oldValue: undefined, value: "foo"}

observable.foo = 'bar'
// >> foo change from foo to bar
// >> {key: "foo", oldValue: "foo", value: "bar"}

DOM rendering

const dom = new Proxy({}, {
  get: (target, key, receiver) => (attrs = {}, children = '') => {
    const isText = typeof children === 'string'
    const attrNames = Object.keys(attrs)
    if (isText && attrNames.length === 0) {
      return children
    }
    const getAttrs = (acc, attr) =>
      `${ acc } ${ attr }='${ attrs[attr] }'`
    return [
      `<${ key }${ attrNames.reduce(getAttrs, '') }>`,
      ...(isText ? [children] : children),
      `</${ key }>\n`
    ].join('\n')
  }
})

dom.span({ class: 'foo' }, ['text'])
// >> "<span class='foo'>text</span>"
dom.form({ class: 'foo' }, [
  dom.label({ for: 'win' }, 'So much'),
  dom.button(
    { type: 'submit', id: 'win' },
    'WIN!!!'
  ),
])
<form class='foo'>
<label for='win'>
So much
</label>
<button type='submit' id='win'>
WIN!!!
</button>
</form>

Getting serious with Proxies

  • Proxy.revocable

  • Object property descriptors interop with Proxies via Invariance enforcement

    • Non-extensibility

    • Non-configurability

  • Reflect uses beyond forwarding operations

Inspiration & sources

Thank you and

stay curious :)

Metaprogramming Superpowers via ES2015 proxies

By Nick Ribal

Metaprogramming Superpowers via ES2015 proxies

All ES2015/16 features can be polyfilled and transpiled to ES5 - except for one: Proxies! Now that these are finally implemented in all evergreen browsers and node 6, we can explore this exciting new feature. Learn all about Proxies and Reflect APIs, their use cases and their unique superpowers!

  • 4,823