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.
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:
- Look up the property
name
on theuser
object - 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.
const user = { name: 'John' }
console.log(user.name) // 'John'
Here's the same thing with a Proxy:
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 theuser
object- The second argument is a handler with methods called "traps"
get
is called every time someone accesses a propertytarget
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:
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 objectproperty
: the property namevalue
: 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:
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:
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:
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:
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:
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:
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:
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:
- Checks if the
users
table exists - If not, creates an empty array
- Returns an object with methods to interact with that table
Array with History
Let's create an array that remembers its changes:
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
:
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:
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:
// 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
:
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.