Fun & Games with ES Proxies
A presentation by Christophe Porteneuve at Confoo Montréal 2020
A presentation by Christophe Porteneuve at Confoo Montréal 2020
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',
],
}
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)
}
})
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.
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.
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.
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 ) |
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.
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).
get
demo: tpyoBy 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.
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]
}
})
}
get
demo: on-the-fly API proxyRemember 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…' }
get
demo: on-the-fly API proxy
|
|
get
+set
demo: negative array indicesGawd 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']
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)
}
})
}
get
(+set
) demo: defensive objectsSometimes 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
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)
}
})
}
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.
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).
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.
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.
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.
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
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
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
}
😍 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.
Writing reducer-style logic becomes outrageously approachable:
Before
|
After
|
Goes delightfully well with React’s (recommended…) function-based setState()
:
Before
|
After
|
// 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))
},
// …
})
// …
}
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
|
Workaround
|
We publish about one new video course every month, mostly around Git and JS.
Or if that’s easier for you:
Christophe Porteneuve
Slides are at bit.ly/confoo-es-proxies