Fun & Games with ES Proxies

A presentation by Christophe Porteneuve at Confoo Montréal 2020

whoami


const christophe = {
  family: { wife: 'Élodie', sons: ['Maxence', 'Elliott'] },
  city: 'Paris, FR',
  company: 'Delicious Insights',
  trainings: ['360° ES', 'Modern Web Apps', 'Node.js', '360° Git', 'Webpack'],
  webDevSince: 1995,
  mightBeKnownFor: [
    'Prototype.js',
    'Prototype and Script.aculo.us',
    'dotJS',
    'Paris Web',
    'NodeSchool Paris',
  ],
}
          

“Proxy…” 🤔

ES proxies let us redefine the semantics of some core language features.

A metaprogramming feature, like Object.* methods and well-known symbols.

Does not alter the original object: wraps it.


            const proxy = new Proxy(origObject, handler)
          

All about AOP, really, so lots of use cases: reactivity / data binding, RBAC, monitoring/logging/timing, delegation…


            const chris = { age: 42.31211498973306 }
            const proxy = new Proxy(christophe, {
              set(target, prop, value, recipient) {
                if (prop === 'age' && (typeof value !== 'number' || value < 0)) {
                  throw new Error(`Invalid age: ${prop}.  Must be a non-negative number.`)
                }
                Reflect.set(target, prop, value, recipient)
              }
            })
          

Vocabulary

Trap

A function with a pre-defined name that intercepts a language interaction to replace or customize it.
It can delegate to the original behavior by using the Reflect API, as we’ll see.

Handler

An object that bundles a series of traps. It is usually single-topic and implements just enough traps for its feature. For instance, negative array indices only need the get and set traps.

Proxy

An object that wraps another and intercepts some or all of the possible language interactions on that object.
The list of interactions is obtained through the methods of a handler passed at proxy creation time.

Available traps

Trap Intercepts…
get Reading a property
set Writing a property
has The in operator
ownKeys Object.keys, Object.getOwnPropertyNames, Object.getOwnPropertySymbols
getOwnPropertyDescriptor Object.getOwnPropertyDescriptor(s)
defineProperty Object.defineProperty
deleteProperty The delete operator
isExtensible Object.isExtensible
preventExtensions Object.preventExtensions
getPrototypeOf Object.getPrototypeOf
setPrototypeOf Object.setPrototypeOf
apply Calling a function
construct Using a function as constructor (new TheFunction)

Accessing the original behavior

The Reflect namespace has methods for every trap, with matching signature.

Sometimes it feels like a duplicate of Object methods, but there could be subtle differences
(e.g. no casting, returning booleans instead of throwing).

In general, “lighter” than matching Object methods.
Sort of corresponds to what the ES spec calls “internal slots,” such as [[Call]].

I tend to always use the Reflect API in my traps, even when it seems easier to go with in, delete or direct property access. This way I can be sure there are no corner cases.

Traps: get and set


            get(target, prop, receiver)
          

Intercepts property reads.

Default behavior leverages the reader accessor if any, and defaults to undefined for missing properties.



              set(target, prop, value, receiver)
            

Intercepts property writes.

Default behavior leverages the writer accessor if any, and creates missing properties on the fly (unless non-extensible).



Note: prop is always a String or Symbol (matching property name constraints).

Fun get demo: tpyo

By Mathias Bynens, v8 engineer, master of deep tech blogging

Redefines property access to use a Levenstein-distance matching on property typos 😂


            const tpyo = require('tpyo')

            const devs = tpyo(
              ['Aleth', 'Aurélie', 'Catherine', 'Claudia', 'Gabriela', 'Julie', 'Laïla', 'Marie', 'Roksolana']
            )
            devs.longueur       // => 9
            devs.plop()         // => 'Roksolana'
            devs.full('of win') // => Of course it is (8 x 'of win')
            devs.calice(-1)     // => ['Marie'] -- same for ostie/crisse ;-)

            const math = tpyo(Math)
            math.squirt(9) // => 3. Sure.
          

Fun get demo: tpyo


            // Simplified a bit for presentation purposes

            function tpyo(something) {
              return new Proxy(something, {
                get(target, name) {
                  if (name in target) {
                    return target[name]
                  }

                  const properties = getProperties(target)
                  const closestProperty = findSimilarProperty(name, properties)
                  return target[closestProperty]
                }
              })
            }
          

Useful get demo: on-the-fly API proxy

Remember the good ol’ times of COM, DCOM, and client-side API proxy generation?

We can do better than that!


              const api = makeRestProxy('https://jsonplaceholder.typicode.com')
              await api.users()
              // => [{ id: 1, name: 'Leanne Graham' … }, { id: 2, name: 'Ervin Howell', … }, …]

              await api.users(1)
              // => { id: 1 name: 'Leanne Graham', username: 'Bret', email: 'Sincere@april.biz', … }

              // (Check out this amazing consistency between fake fields 😅)

              await api.posts(42)
              // => { userId: 5, id: 42, title: 'commodi ullam…', body: 'odio fugit…' }
          

Useful get demo: on-the-fly API proxy


                  function makeRestProxy(baseURL) {
                    return new Proxy({}, {
                      get(target, prop, receiver) {
                        if (!(prop in target)) {
                          Reflect.defineProperty(target, prop, {
                            value: makeFetchCall(baseURL, prop)
                          })
                        }
                        return Reflect.get(target, prop, receiver)
                      }
                    })
                  }
                

                  // Quite simplified for presentation purposes

                  function makeFetchCall(baseURL, prop) {
                    return function fetch(id) {
                      const path = id == null ? '' : `/${id}`
                      return fetch(`${baseURL}/${prop}${path}`, {
                        headers: {
                          Accept: 'application/json',
                          'Content-Type': 'application/json',
                        },
                      }).then((res) => res.json())
                    }
                  }
                

Useful get+set demo: negative array indices

Gawd knows we miss them (from Ruby, etc.) and lastItem* is nowhere near them.


            const names = ['Alice', 'Bob', 'Claire', 'David']
            const coolNames = allowNegativeIndices(names)

            coolNames[-1]
            // => 'David'

            coolNames[-2] = 'Clara'
            names
            // => ['Alice', 'Bob', 'Clara', 'David']
          

Useful get+set demo: negative array indices


            function allowNegativeIndices(arr) {
              return new Proxy(arr, {
                get(target, prop, receiver) {
                  if (prop < 0) {
                    prop = target.length + Number(prop)
                  }
                  return Reflect.get(target, prop, receiver)
                },
                set(target, prop, value, receiver) {
                  if (prop < 0) {
                    prop = target.length + Number(prop)
                  }
                  return Reflect.set(target, prop, value, receiver)
                }
              })
            }
          

Useful get (+set) demo: defensive objects

Sometimes you don’t want undefined on missing props, you want a bona fide exception!


            const basis = { first: 'Phil', last: 'Hawksworth' }
            const defensive = makeDefensive(basis)
            defensive.first  // => 'Phil'
            defensive.middle // => ReferenceError: No middle property on object
          

Useful get (+set) demo: defensive objects


            function makeDefensive(obj) {
              return new Proxy(obj, {
                get(target, prop, receiver) {
                  if (!(prop in target)) {
                    throw new ReferenceError(`No ${prop} property on object`)
                  }
                  return Reflect.get(target, prop, receiver)
                }
              })
            }
          

Traps: getOwnPropertyDescriptor, has and ownKeys

getOwnPropertyDescriptor(target, prop)

For the singular and plural versions. Could be nasty, but invariants prevent much nefariousness.


has(target, prop)

Used specifically by the in operator. The least you can do for “phantom properties.” Subject to invariants too.


ownKeys(target)

Obviously Object.keys(), but also own-property listings (Object.getOwnPropertyNames() and Object.getOwnPropertySymbols(). Invariants again.

Traps: defineProperty and deleteProperty


            defineProperty(target, prop, descriptor)
          

Returns a boolean, subject to the usual invariants (around configurability and extensibility). Descriptor is normalized.



              deleteProperty(target, prop)
            

Used by the delete operator. Returns a boolean, subject to invariant (configurability consistency).

Traps: isExtensible and preventExtensions


            isExtensible(target)
          

For decoration only: has to forward the call anyway.



              preventExtensions(target)
            

Lets us intercept, but subject to return value consistency invariant.

Traps: getPrototypeOf and setPrototypeOf


            getPrototypeOf(target)
          

Returns an object or null, subject to a consistency invariant.



              setPrototypeOf(target, prototype)
            

Returns a boolean, subject to a consistency invariant.

Traps: apply and construct

These require targets to be functions.


            apply(target, thisArg, argumentsList)
          

Intercepts the call to a function (the (…) operator), plus API-based variants: .apply(…), .call(…).

Particulary useful for copy-on-write implementations that need to automatically wrap method return values in proxies.



              construct(target, argumentsList, newTarget)
            

Intercepts using the new operator on the function. The result must be an object.

We don’t usually care about newTarget, unless we want to work around a new.target check that is hindering us.

Revocable proxies

An alternate construction method lets us revoke access to the underlying object (through the proxy, that is) at any time, for any reason. Makes for “perishable references,” so to speak.


            const { proxy, revoke } = Proxy.revocable(target, handler)
          

This clearly has neat use-cases in security-related scenarios.


            const { proxy, revoke } = Proxy.revocable({ first: 'John' }, {})

            proxy.first // => 'John'
            revoke()
            proxy.first // => TypeError: Cannot perform 'get' on a proxy that has been revoked
          

Useful revocable demo: metered access


            const obj = { first: 'John' }
            const moth = scheduleExpiry(obj, { ttl: 50 })

            moth.first // => 'John'
            setTimeout(() => console.log(moth.first), 40) // => 'John' after 40ms
            setTimeout(() => console.log(moth.first), 60) // => TypeError after 60ms
          

            const fx = (...args) => args
            const meteredFx = meter(fx, { max: 2 })
            meteredFx('foo')        // => ['foo']
            meteredFx('bar', 'baz') // => ['bar', 'baz']
            meteredFx('fuu')        // => TypeError: Cannot perform 'apply' on a proxy that has been revoked
          

Useful revocable demo: metered access


            function scheduleExpiry(obj, { ttl = 100 } = {}) {
              const { proxy, revoke } =  Proxy.revocable(obj, {})
              setTimeout(revoke, ttl)
              return proxy
            }
          

            function meter(fx, { max }) {
              const { proxy, revoke } = Proxy.revocable(fx, {
                apply(target, thisArg, argumentsList) {
                  if (--max <= 0) {
                    revoke()
                  }
                  return Reflect.apply(target, thisArg, argumentsList)
                }
              })
              return proxy
            }
          

Pure awesomeness: Immer

😍 Amazing 😍 immutability helper by Michel Westrate (also of MobX fame). Let us write usual mutative code!

Copy-on-write for nested structures using recursive revocable proxying with almost every trap 😅


            import produce from 'immer'

            const baseState = [
              { todo: 'Learn React', done: true },
              { todo: 'Try immer', done: false },
            ]

            const nextState = produce(baseState, (draftState) => {
              draftState.push({ todo: 'Tweet about it' })
              draftState[1].done = true
            }) // => baseState untouched, nextState correct.
          

Pure awesomeness: Immer

Writing reducer-style logic becomes outrageously approachable:

Before


                    function byId(state, action) {
                      switch (action.type) {
                        case RECEIVE_PRODUCTS:
                          return {
                            ...state,
                            ...action.products.reduce((obj, product) => {
                              obj[product.id] = product
                              return obj
                            }, {})
                          }
                        default:
                          return state
                      }
                    }
                  

After


                    const byId = produce((draft, action) => {
                      switch (action.type) {
                        case RECEIVE_PRODUCTS:
                          for (const product of action.products) {
                            draft[product.id] = product
                          }
                        }
                    })
                  

Pure awesomeness: Immer

Goes delightfully well with React’s (recommended…) function-based setState():

Before


                    this.setState((prevState) => ({
                      ...prevState,
                      user: {
                        ...prevState.user,
                        age: prevState.user.age + 1
                      }
                    }))
                  

After


                    this.setState(
                      produce((draft) => {
                        draft.user.age += 1
                      })
                    )
                  

Pure awesomeness: Immer


            // Chosen extracts, simplified for presentation purposes
            export function createProxy(base, parent) {
              // …
              const {revoke, proxy} = Proxy.revocable(state, {
                get(state, prop) {
                  if (prop === DRAFT_STATE) return state
                  let { drafts } = state
                  if (!state.modified && has(drafts, prop)) {
                    return drafts[prop]
                  }
                  // …
                  return (drafts[prop] = createProxy(value, state))
                },
                // …
              })
              // …
            }
          

<Insert obligatory pitfall here>

There are issues with this. (Oh noes, not again…)

Proxy wrapping affects this (it becomes the proxy).

Issues with identity-based mechanisms such as WeakMap (e.g. emulating private instance fields).

Issues with built-in objects with methods that use internal slots (e.g. Date#getDate()) : circumvents get/set traps.

Issue


                    const target = new Date()
                    const proxy = new Proxy(target, {})

                    proxy.getDate()
                    // => TypeError: this is not a Date object
                    // (because `getDate` relies on [[NumberData]])
                  

Workaround


                    const target = new Date('2019-10-03')
                    const proxy = new Proxy(target, {
                      get(target, prop, receiver) {
                        const result = Reflect.get(target, prop, receiver)
                        return prop === 'getDate' ? result.bind(target) : result
                      }
                    })

                    proxy.getDate() // => 3
                  

Do you like screencasts?

We publish about one new video course every month, mostly around Git and JS.


screencasts.delicious-insights.com


Or if that’s easier for you:


bit.ly/screencasts-confoo

Thank you!

Always bet on JS.


Christophe Porteneuve

@porteneuve

Slides are at bit.ly/confoo-es-proxies