Advanced JavaScript
advanced
#proxies#reflect#meta-programming

How JavaScript Proxies work and when to use them.

JavaScript Proxies let you intercept object operations like property access and assignment. This tutorial covers proxy traps, the Reflect API, use cases in frameworks, and practical coding patterns.

July 7, 2025
12 min read
Share this article:

JavaScript Proxies let you intercept operations on objects - like getting or setting properties. This means you can run custom code whenever someone accesses obj.name or sets obj.age = 25.

Most developers don't know about Proxies, but they're behind many features you use daily. Vue.js uses them to make data reactive. Testing libraries use them to create spies. Let's learn how they work and when to use them.

What Happens When You Access a Property?

When you write user.name, JavaScript does this:

  1. Look up the property name on the user object
  2. Return its value

A Proxy lets you hook into step 1. You can run code before JavaScript gets the property, and you can change what gets returned.

javascript
const user = { name: 'John' }
console.log(user.name) // 'John'

Here's the same thing with a Proxy:

javascript
const user = { name: 'John' }

const userProxy = new Proxy(user, {
  get(target, property) {
    console.log(`Getting ${property}`)
    return target[property]
  },
})

console.log(userProxy.name) // Logs "Getting name", then returns 'John'

Let's break this down:

  • new Proxy(user, handler) creates a proxy around the user object
  • The second argument is a handler with methods called "traps"
  • get is called every time someone accesses a property
  • target is the original object (user)
  • property is the property name being accessed ('name')
  • target[property] gets the actual value from the original object

Your First Useful Proxy

Let's create an object that logs every property access:

javascript
function createLogger(obj) {
  return new Proxy(obj, {
    get(target, property) {
      console.log(`Reading: ${property}`)
      return target[property]
    },

    set(target, property, value) {
      console.log(`Writing: ${property} = ${value}`)
      target[property] = value
      return true
    },
  })
}

const user = createLogger({ name: 'John', age: 30 })
user.name // Logs: "Reading: name"
user.age = 31 // Logs: "Writing: age = 31"

The set trap works like get but for setting properties. It receives:

  • target: the original object
  • property: the property name
  • value: the new value

You must return true to indicate the assignment succeeded.

Default Values with Proxies

Here's a practical example - an object that returns a default value for missing properties:

javascript
function withDefaults(obj, defaultValue) {
  return new Proxy(obj, {
    get(target, property) {
      if (property in target) {
        return target[property]
      }
      return defaultValue
    },
  })
}

const config = withDefaults({ port: 3000 }, 'NOT_SET')
console.log(config.port) // 3000
console.log(config.host) // 'NOT_SET'
console.log(config.database) // 'NOT_SET'

This checks if the property exists with property in target. If it does, return the real value. If not, return the default.

The Reflect API

Before going further, let's talk about Reflect. It's an object with methods that do the same things as Proxy traps:

javascript
const obj = { name: 'Alice' }

// These are equivalent:
obj.name
Reflect.get(obj, 'name')

// These are equivalent:
obj.age = 25
Reflect.set(obj, 'age', 25)

Why use Reflect? It's the standard way to perform the "default" behavior in your proxy traps:

javascript
const proxy = new Proxy(obj, {
  get(target, property) {
    // Do some custom logic
    console.log(`Getting ${property}`)

    // Then do the default behavior
    return Reflect.get(target, property)
  },
})

Property Validation

Let's build something more useful - an object that validates values when you set them:

javascript
function createUser() {
  const data = {}

  return new Proxy(data, {
    set(target, property, value) {
      if (property === 'age') {
        if (typeof value !== 'number' || value < 0) {
          throw new Error('Age must be a positive number')
        }
      }

      if (property === 'email') {
        if (!value.includes('@')) {
          throw new Error('Email must contain @')
        }
      }

      target[property] = value
      return true
    },
  })
}

const user = createUser()
user.name = 'John' // OK
user.age = 25 // OK
user.email = 'john@test.com' // OK

user.age = -5 // Error: Age must be a positive number
user.email = 'invalid' // Error: Email must contain @

This validates each property before setting it. If validation fails, it throws an error. If it passes, it sets the property normally.

Case-Insensitive Objects

Here's another practical example - an object where property names ignore case:

javascript
function createCaseInsensitive(initialData = {}) {
  const data = {}

  // Convert all initial keys to lowercase
  for (const key in initialData) {
    data[key.toLowerCase()] = initialData[key]
  }

  return new Proxy(data, {
    get(target, property) {
      return target[property.toLowerCase()]
    },

    set(target, property, value) {
      target[property.toLowerCase()] = value
      return true
    },
  })
}

const config = createCaseInsensitive({ ApiKey: 'secret123' })
console.log(config.apikey) // 'secret123'
console.log(config.APIKEY) // 'secret123'
console.log(config.ApiKey) // 'secret123'

This converts all property names to lowercase before getting or setting them.

Tracking Property Access

Let's create an object that tracks which properties get accessed:

javascript
function createTracker(obj) {
  const accessed = new Set()

  const proxy = new Proxy(obj, {
    get(target, property) {
      if (property === 'getAccessed') {
        return () => Array.from(accessed)
      }

      accessed.add(property)
      return target[property]
    },
  })

  return proxy
}

const user = createTracker({ name: 'John', age: 30, email: 'john@test.com' })
user.name // Access 'name'
user.age // Access 'age'

console.log(user.getAccessed()) // ['name', 'age']

This tracks every property access in a Set, and provides a getAccessed method to see which properties were accessed.

Building a Simple Database

Here's a more complex example - a simple in-memory database:

javascript
function createDatabase() {
  const tables = {}

  return new Proxy(tables, {
    get(target, tableName) {
      if (!target[tableName]) {
        target[tableName] = []
      }

      return {
        insert(record) {
          target[tableName].push(record)
        },

        find(predicate) {
          return target[tableName].filter(predicate)
        },

        all() {
          return target[tableName]
        },
      }
    },
  })
}

const db = createDatabase()

// Insert some users
db.users.insert({ name: 'John', age: 30 })
db.users.insert({ name: 'Jane', age: 25 })

// Query users
console.log(db.users.find((user) => user.age > 25)) // [{ name: 'John', age: 30 }]
console.log(db.users.all()) // All users

When you access db.users, the proxy:

  1. Checks if the users table exists
  2. If not, creates an empty array
  3. Returns an object with methods to interact with that table

Array with History

Let's create an array that remembers its changes:

javascript
function createHistoryArray(initialData = []) {
  const data = [...initialData]
  const history = []

  return new Proxy(data, {
    set(target, property, value) {
      // Only track numeric indices (array elements)
      if (typeof property === 'string' && /^\d+$/.test(property)) {
        const index = parseInt(property)
        history.push({
          index,
          oldValue: target[index],
          newValue: value,
          timestamp: Date.now(),
        })
      }

      target[property] = value
      return true
    },

    get(target, property) {
      if (property === 'getHistory') {
        return () => [...history]
      }

      if (property === 'undo') {
        return () => {
          if (history.length === 0) return false

          const lastChange = history.pop()
          target[lastChange.index] = lastChange.oldValue
          return true
        }
      }

      return target[property]
    },
  })
}

const arr = createHistoryArray([1, 2, 3])
arr[0] = 10
arr[1] = 20

console.log(arr.getHistory())
// [
//   { index: 0, oldValue: 1, newValue: 10, timestamp: ... },
//   { index: 1, oldValue: 2, newValue: 20, timestamp: ... }
// ]

arr.undo() // Reverts arr[1] = 20
console.log(arr) // [10, 2, 3]

This tracks every change to array elements and provides an undo method to revert the last change.

Common Proxy Traps

Here are the most useful proxy traps:

get(target, property) - Called when reading properties set(target, property, value) - Called when setting properties has(target, property) - Called for in operator checks deleteProperty(target, property) - Called for delete operations

Example using has:

javascript
const secretObj = new Proxy(
  { secret: 'hidden', public: 'visible' },
  {
    has(target, property) {
      if (property === 'secret') {
        return false // Hide the secret property
      }
      return property in target
    },
  },
)

console.log('public' in secretObj) // true
console.log('secret' in secretObj) // false (even though it exists)

When Not to Use Proxies

Proxies add overhead. Here's a simple benchmark:

javascript
const obj = { count: 0 }
const proxy = new Proxy(
  { count: 0 },
  {
    get: (target, prop) => target[prop],
    set: (target, prop, value) => ((target[prop] = value), true),
  },
)

console.time('direct')
for (let i = 0; i < 1000000; i++) obj.count++
console.timeEnd('direct')

console.time('proxy')
for (let i = 0; i < 1000000; i++) proxy.count++
console.timeEnd('proxy')

Proxies are typically 2-3x slower than direct property access. Use them when the benefits outweigh the cost.

Browser Support

Proxies work in:

  • Chrome 49+
  • Firefox 18+
  • Safari 10+
  • Edge 12+
  • Node.js 6+

They cannot be polyfilled because they intercept fundamental language operations.

Common Mistakes

Infinite loops: Don't reference the proxy inside its own traps:

javascript
// BAD
const proxy = new Proxy(
  {},
  {
    get(target, property) {
      return proxy[property] // This creates infinite recursion
    },
  },
)

// GOOD
const proxy = new Proxy(
  {},
  {
    get(target, property) {
      return target[property] // Reference the target
    },
  },
)

Forgetting to return true from set: The set trap must return true:

javascript
const proxy = new Proxy(
  {},
  {
    set(target, property, value) {
      target[property] = value
      // Must return true, or the assignment will fail in strict mode
      return true
    },
  },
)

Real-World Use Cases

Vue.js reactivity: Vue uses Proxies to detect when data changes and update the DOM automatically.

Testing spies: Libraries like Sinon use Proxies to track function calls.

API clients: Create dynamic API clients where any property access makes an HTTP request.

Configuration objects: Provide default values or validation for config settings.

Debugging: Log all property access to understand how objects are being used.

Summary

Proxies let you intercept operations on objects. The most common use cases are:

  • Logging property access
  • Providing default values
  • Validating data
  • Creating reactive systems
  • Building dynamic APIs

Start with simple examples like logging, then work up to more complex patterns. Remember that Proxies add overhead, so use them when the benefits are worth the cost.

The key is understanding that Proxies don't change the object - they wrap it and intercept operations. This lets you add behavior without modifying the original object.