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:

python
    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 150

The 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:

javascript
    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
    )
python
    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:

javascript
    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 -> f3

Lazy Loading Proxy

A proxy that loads data only when accessed:

javascript
    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 queried

Change-Tracking Proxy

Now we reach the crucial application: tracking changes for synchronization.

javascript
    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:

javascript
    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:

javascript
    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:

wollok
    // 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 20

Proxies vs Direct Modification

Why use proxies instead of modifying objects directly?

\textwidth
Direct ModificationProxy
TransparencyCode knows about trackingCode is unaware
CouplingTracking mixed with logicTracking separate
FlexibilityMust modify each objectWrap any object
PerformanceSlightly fasterSlight overhead
DebuggingEasier to traceOne 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:

python
    // 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 object

Proxies 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.