The Invisible Wrapper
What if you could intercept every interaction with an object? Every field read, every field write, every method call---all passing through a guardian that can observe, validate, or transform them. This is the proxy: an invisible wrapper that mediates all access.
The Basic Idea
A proxy wraps an object and intercepts operations on it:
original = { name: "Alice", score: 100 }
proxied = new Proxy(original, {
get(target, property):
print("Someone is reading: " + property)
return target[property]
set(target, property, value):
print("Someone is writing: " + property + " = " + value)
target[property] = value
return true
})Now watch what happens:
x = proxied.name
// Prints: "Someone is reading: name"
// x is now "Alice"
proxied.score = 150
// Prints: "Someone is writing: score = 150"
// original.score is now 150The proxy intercepts every property access. The calling code is unaware---it thinks it's working with a normal object.
Validation Proxy
Let's build a proxy that validates data:
function createValidatedPiece(piece, rules):
return new Proxy(piece, {
set(target, property, value):
if property in rules:
rule = rules[property]
if not rule.validate(value):
throw "Invalid " + property + ": " + rule.message
target[property] = value
return true
})
validationRules = {
position: {
validate: (pos) => isValidSquare(pos),
message: "Must be a valid chess square (a1-h8)"
},
hasMoved: {
validate: (val) => typeof(val) == "boolean",
message: "Must be true or false"
}
}
knight = createValidatedPiece(
{ type: "knight", position: "g1", hasMoved: false },
validationRules
) knight.position = "f3" // OK
knight.position = "z9" // Throws: "Invalid position: Must be valid square"
knight.hasMoved = true // OK
knight.hasMoved = "yes" // Throws: "Invalid hasMoved: Must be true or false"Logging Proxy
A proxy that logs all access:
function createLoggingProxy(obj, name):
return new Proxy(obj, {
get(target, property):
value = target[property]
log("[READ] " + name + "." + property + " -> " + value)
return value
set(target, property, value):
oldValue = target[property]
log("[WRITE] " + name + "." + property +
": " + oldValue + " -> " + value)
target[property] = value
return true
})
knight = createLoggingProxy(
{ type: "knight", position: "g1" },
"whiteKnight"
)
pos = knight.position
// [READ] whiteKnight.position -> g1
knight.position = "f3"
// [WRITE] whiteKnight.position: g1 -> f3Lazy Loading Proxy
A proxy that loads data only when accessed:
function createLazyProxy(loader):
data = null
loaded = false
return new Proxy({}, {
get(target, property):
if not loaded:
log("Loading data...")
data = loader()
loaded = true
return data[property]
})
// Expensive operation deferred until needed
gameHistory = createLazyProxy(() => {
return fetchFromDatabase("SELECT * FROM moves")
})
// No database call yet!
// ... later ...
firstMove = gameHistory[0] // NOW the database is queriedChange-Tracking Proxy
Now we reach the crucial application: tracking changes for synchronization.
function createTrackedObject(obj, onChange):
return new Proxy(obj, {
set(target, property, value):
oldValue = target[property]
target[property] = value
if oldValue != value:
onChange({
type: "Set",
field: property,
oldValue: oldValue,
newValue: value
})
return true
})
changes = []
knight = createTrackedObject(
{ type: "knight", position: "g1", hasMoved: false },
(change) => changes.push(change)
)
knight.position = "f3"
knight.hasMoved = true
// changes is now:
// [
// { type: "Set", field: "position", oldValue: "g1", newValue: "f3" },
// { type: "Set", field: "hasMoved", oldValue: false, newValue: true }
// ]Deep Tracking
Real objects are nested. We need proxies all the way down:
function createDeepTrackedObject(obj, onChange, path = ""):
return new Proxy(obj, {
get(target, property):
value = target[property]
if isObject(value):
// Return a proxy for nested objects too
newPath = path ? path + "." + property : property
return createDeepTrackedObject(value, onChange, newPath)
return value
set(target, property, value):
oldValue = target[property]
target[property] = value
fullPath = path ? path + "." + property : property
onChange({
type: "Set",
path: fullPath,
oldValue: oldValue,
newValue: value
})
return true
})
gameState = createDeepTrackedObject({
board: {
pieces: {
whiteKing: { position: "e1", hasMoved: false }
}
},
turn: "white"
}, (change) => changes.push(change))
gameState.board.pieces.whiteKing.position = "e2"
// Tracked! { type: "Set", path: "board.pieces.whiteKing.position",
// oldValue: "e1", newValue: "e2" }The Proxy Trap
Proxies can intercept many operations, not just get and set:
handler = {
get(target, prop): // reading a property
set(target, prop, value): // writing a property
has(target, prop): // the 'in' operator
deleteProperty(target, prop): // the 'delete' operator
apply(target, thisArg, args): // function call
construct(target, args): // 'new' operator
}This enables sophisticated patterns:
// A function that counts its calls
function createCountedFunction(fn):
count = 0
return new Proxy(fn, {
apply(target, thisArg, args):
count = count + 1
log("Call #" + count)
return target.apply(thisArg, args)
})
countedDouble = createCountedFunction((x) => x * 2)
countedDouble(5) // "Call #1", returns 10
countedDouble(10) // "Call #2", returns 20Proxies vs Direct Modification
Why use proxies instead of modifying objects directly?
| Direct Modification | Proxy | |
|---|---|---|
| Transparency | Code knows about tracking | Code is unaware |
| Coupling | Tracking mixed with logic | Tracking separate |
| Flexibility | Must modify each object | Wrap any object |
| Performance | Slightly faster | Slight overhead |
| Debugging | Easier to trace | One more layer |
Proxies shine when:
- You can't modify the original code
- The behavior should be invisible to users
- You need to wrap many different types uniformly
The Observer Pattern, Revisited
Proxies are a modern implementation of the Observer pattern:
// Traditional Observer
class ObservableKnight:
observers = []
position = "g1"
setPosition(newPos):
oldPos = position
position = newPos
for observer in observers:
observer.notify("position", oldPos, newPos)
// Proxy-based Observer
knight = createTrackedObject(
{ position: "g1" },
(change) => notifyAll(observers, change)
)
// Same effect, but knight is a plain objectProxies intercept changes as they happen. But sometimes we can't use proxies---perhaps we're working with a library's objects, or we need to compare states across complex operations. The next chapter explores an alternative: taking snapshots and computing what changed.