We're about to create the JavaScript equivalent of a magic trick. Before we do, I'm going to ask for a little bit of patience for a few reasons...
We're not going to be using TypeScript for this. Sorry not sorry. I default to typescript too just like most out there, but it has some pretty frustrating and evident limitations for what we're going to play with. We'll go over that in the end.
The concepts we're diving into are a bit on the advanced - or maybe better stated as peculiar - side of JS.
Some may consider using the parts of what we're going to use akin to walking the line of being kicked out of the Doug Crockford club.
I've been writing JS/TS now going on almost 2 decades. The longer I've been at it, the more I realize how much of the language I never touch. I remember excitedly reading someone's post about what's coming in the next version ES2xxx (and then forgetting about it quickly), but content today is library focused. And that's fine, but I hope this can convince you to do a rabbit hole session on MDN every once in a while.
The magic trick: "Impossible" Reactivity
const state = createReactiveState({
user: {
name: "Alice",
theme: "dark"
}
})
const userName = state.user.name // "Alice"
const userTheme = state.user.theme // "dark"
console.log(`User: ${userName}, Theme: ${userTheme}`)
// → "User: Alice, Theme: dark"
state.user = { name: "Bob", theme: "light" }
console.log(`User: ${userName}, Theme: ${userTheme}`)
// → "User: Bob, Theme: light" 🤯
This looks impossible! How did userName
and userTheme
get the new values? Not only did we completely replace the object references, but aren't those just plain JS variables? How did they change?
🎶 Come with me, and we'll see... 🎶
Cosplaying Functions
Not a technical term.
Let's start off by giving our code an identity crisis. If you didn't already know, functions are objects in JS. Not a big surprise for most I hope. Did you know, however, that functions can also disguise themselves as certain primitive values?
Yep. JavaScript Objects and Functions have a few special methods attached that will allow it to shape-shift so they can become operable against other primitive values. There are three of them: valueOf()
, toString()
, and [Symbol.toPrimitive]()
.
Right now, we're only going to focus on the last one: [Symbol.toPrimitive]()
. It looks weird. This is a built-in symbol that can attach as a property method on any object or function. It takes a single parameter: hint
. The hint
parameter will be one of three strings: "string"
, "number"
, and "default"
.
How it works
JavaScript automatically invokes this method when our original function is being used as if it were a primitive value. We then return what we want our function to convert to based on the hint parameter. The only rule is that we must return a primitive value. If we don't, it'll throw a Type Error
.
The hint
parameter will tell us how our function is trying to be coerced.
function WolfInWool() {/*...*/}
WolfInWool[Symbol.toPrimitive] = (hint) => {
if (hint === 'string') {
return "I'm a wolf in sheep's clothing."
}
if (hint === 'number') {
return 3
}
return true
}
// We're trying to add to a number, return a number
console.log(WolfInWool + 3)
// 6
// We're trying to append to a string, return a string
console.log(`The wolf says, "${WolfInWool}"`)
// "The wolf says, "I'm a wolf in sheep's clothing.""
NOTE: This can also be done with a plain JS object instead of a function.
This function is a function, but it's also secretly a shape-shifter. When JavaScript needs a primitive value—for comparison, concatenation, or arithmetic—this function decides what to become. It's like having an object that can cosplay as any data type, and it gets to choose its costume based on the context.
What's interesting here isn't just that it works—it's that the function is participating in its own conversion. It's not passive; it's making decisions about how to represent itself based on what JavaScript is asking for.
Wiretapping (with Proxies)
Okay, so we've got functions that can masquerade as primitives. That's one piece of our magic trick. But how do we make userName
and userTheme
stay connected to the state? How do we intercept when someone accesses state.user.name
in the first place?
This is where we need to "wiretap" property access. And JavaScript gives us the perfect tool for this: Proxies.
Proxies have a reputation for being complex, and honestly, that reputation isn't entirely unfair. The typical explanation is "objects that intercept property access." That's like describing an F1 car as "transportation". Technically accurate...just not flattering enough.
Proxies can monitor every conversation between your code and your objects. They can remember not just what was accessed, but the entire path of how you got there. Nested proxies can maintain a complete surveillance network that spans your entire object graph.
So how does this connect to our magic trick? Remember this seemingly impossible behavior:
const userName = state.user.name // "Alice"
// Later...
state.user = { name: "Bob", theme: "light" }
console.log(`User: ${userName}`) // "User: Bob" 🤯
Here's the secret: when you access state.user.name
, you're not actually getting a string, you're getting one of those cosplaying functions we just learned about. The proxy intercepts your property access and hands you back a function that looks like "Alice" but is secretly connected to the original state.
Let's see this in action with a simple example:
function createLiveValue(sourceObj, path) {
// Create our cosplaying function
const liveValue = () => sourceObj[path]
// Give it shape-shifting powers
liveValue[Symbol.toPrimitive] = (hint) => {
const currentValue = sourceObj[path]
console.log(`🎭 Converting ${path} to ${hint}: ${currentValue}`)
return currentValue
}
return liveValue
}
const data = { name: "Alice" }
const proxy = new Proxy(data, {
get(target, prop) {
console.log(`🕵️ Someone accessed: ${prop}`)
return createLiveValue(target, prop)
}
})
const name = proxy.name // Gets our special function, not "Alice"
console.log(`Hello ${name}`) // "Hello Alice" - function converts!
// Change the source
data.name = "Bob"
console.log(`Hello ${name}`) // "Hello Bob" - still connected! 🎭
The proxy intercepted proxy.name
and gave us back a function instead of the string. But because of Symbol.toPrimitive
, that function seamlessly converts to "Alice" when we use it. When we change the source data, the function reflects the new value because it's always reading from the original object.
Proxies, proxies, and more proxies
Our simple proxy works great for flat objects, but what about state.user.name
? We need state.user
to return something, and then that something needs to handle .name
. This is where proxies get interesting.
Instead of returning a simple value when we hit an object, we return another proxy. Each proxy in the chain remembers where it came from, building up a complete ancestry of how we got there.
function getValueAtPath(obj, path) {
return path.reduce((current, key) => current?.[key], obj)
}
function createLiveTrackingProxy(target, rootState = null, pathSoFar = []) {
// If this is the first call, rootState becomes our source of truth
if (rootState === null) {
rootState = target
}
return new Proxy(target, {
get(obj, prop) {
// Don't mess with JavaScript's internal symbol operations
if (typeof prop === 'symbol') return obj[prop]
// Get the actual value so we can decide what to do with it
const value = obj[prop]
// Build the complete path by adding current property to our ancestry
const currentPath = [...pathSoFar, prop]
console.log(`🔍 Accessing: ${currentPath.join('.')}`)
// If it's a primitive, return our shape-shifting function
if (isPrimitive(value)) {
console.log(`🎭 Creating live function for: ${currentPath.join('.')}`)
return createLiveFunction(rootState, currentPath)
}
// If it's an object, return ANOTHER proxy that knows its ancestry
if (typeof value === 'object' && value !== null) {
console.log(`🔗 Creating proxy chain for: ${currentPath.join('.')}`)
return createLiveTrackingProxy(value, rootState, currentPath)
}
return value
}
})
}
function createLiveFunction(rootState, path) {
const liveValue = () => getValueAtPath(rootState, path)
liveValue[Symbol.toPrimitive] = (hint) => {
const currentValue = getValueAtPath(rootState, path)
console.log(`🎭 ${path.join('.')} converting (${hint}): ${currentValue}`)
return currentValue
}
return liveValue
}
function isPrimitive(value) {
return value === null ||
value === undefined ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
}
Now let's see this proxy chain in action:
const state = createLiveTrackingProxy({
user: {
profile: { name: "Alice", age: 30 },
settings: { theme: "dark" }
}
})
// This innocent looking access...
const userName = state.user.profile.name
// Actually creates this chain:
// 🔍 Accessing: user
// 🔗 Creating proxy chain for: user
// 🔍 Accessing: user.profile
// 🔗 Creating proxy chain for: user.profile
// 🔍 Accessing: user.profile.name
// 🎭 Creating live function for: user.profile.name
console.log(`Hello ${userName}`)
// 🎭 user.profile.name converting (string): Alice
// "Hello Alice"
// Now change the root and watch the magic
state.user.profile.name = "Bob"
console.log(`Hello ${userName}`)
// 🎭 user.profile.name converting (string): Bob
// "Hello Bob" ✨
Each proxy in the chain carries the complete DNA of its ancestry. When you access state.user.profile.name
:
-
state.user
creates a proxy that knows it's at path['user']
-
.profile
creates a proxy that knows it's at path['user', 'profile']
-
.name
hits a primitive, so it returns a function that knows it lives at['user', 'profile', 'name']
The function always reads from the root state using its complete path, so it stays live no matter how deep the nesting goes.
The Complete Picture
And there you have it - our "impossible" reactivity explained! The userName
variable stays connected because it's actually a shape-shifting function that knows its path back to the root state, courtesy of our proxy chain gang.
What looked like magic was just two underused JavaScript features working together: functions that can cosplay as primitives, and nested proxies that remember their ancestry.
If this has tickled your fancy, here's a more complete example you can play with that includes automatic dependency tracking, computed values that update when their dependencies change, and a few other interesting features. Fair warning: it gets pretty wild.
I promised a TypeScript explanation...
TypeScript's static analysis operates at compile time, but the features we just explored make decisions at runtime. This creates some fundamental tensions:
Symbol.toPrimitive is a mystery: TypeScript can't predict what primitive type will be returned because that decision happens when JavaScript calls the method. Will myFunction + 5
return a number? A string? TypeScript genuinely doesn't know.
const shapeshifter = {
[Symbol.toPrimitive](hint) {
return hint === 'number' ? 42 : 'hello'
}
}
const result = shapeshifter + 10 // What type is this? 🤷♂️
Proxies break property assumptions: TypeScript assumes it knows what properties exist on your objects. But our proxies dynamically create properties and return functions instead of the expected values.
const state: any = createLiveTrackingProxy({ user: { name: "Alice" } })
const name = state.user.name // TypeScript: "I give up. This is 'any'."
This isn't a failing of TypeScript - it's doing exactly what it's designed to do. Static type systems work by building a complete model of your program before it runs. These JavaScript features deliberately defer decisions until runtime, making them fundamentally incompatible with static analysis.
The Exploration Mindset
JavaScript is weird. It's full of features that seem strange until you see them solve problems you didn't know you had. The language's willingness to let you redefine fundamental operations—object property access, primitive conversion, function behavior—is exactly what makes it capable of magic tricks.
Top comments (2)
Pretty amazing. I love working with proxies.
Ditto! It is a very under-utilized feature of the language.