Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
PHP Magic Methods in JavaScript

JavaScript Magic Methods

This script implements some of PHP's magic methods for JavaScript classes, using a Proxy.

Example

You can use it like this:

const Foo = magicMethods(class Foo {
  constructor () {
    this.bar = 'Bar'
  }
  
  __get (name) {
    return `[[${name}]]`
  }
})

const foo = new Foo
foo.bar // "Bar"
foo.baz // "[[baz]]"

If you're using a JavaScript transpiler like Babel with decorators enabled, you can also use the magicMethods function as a decorator:

@magicMethods
class Foo {
  // ...
}

Supported Magic Methods

Given a class Class and an instance of it, the following are the magic methods supported by this script:

__get(name)

Called when trying to access instance[name] where name is not an existing property of instance.

Attention: As in PHP, the check if name exists in instance does not use any custom __isset() methods.

__set(name, value)

Called when trying to do instance[name] = ... where name is neither set as a property of instance.

__isset(name)

Called when trying to check existance of name by calling name in instance.

__unset(name)

Called when trying to unset property name by calling delete instance[name].

Additional Methods

The following magic methods are made available by this script, but are not supported in PHP:

static __getStatic(name)

Like __get(), but in the Class instead of the instance.

static __setStatic(name, value)

Like __set(), but in the Class instead of the instance.

Why is Magic Method X not supported?

They are either not necessary or not practical:

  • __construct() is not needed, there's JavaScript's constructor already.
  • __destruct(): There is no mechanism in JavaScript to hook into object destruction.
  • __call(): As opposed to PHP, methods are just like properties in JavaScript and are first obtained via __get(). To implement __call(), you simply return a function from __get().
  • __callStatic(): As in __call(), but with __getStatic().
  • __sleep(), __wakeup(): There's no builtin serialization/unserialization in JavaScript. You could use JSON.stringify()/JSON.parse(), but there's no mechanism to automatically trigger any methods with that.
  • __toString() is already present in JavaScript's toString()
  • __invoke(): JavaScript will throw an error if you'll try to invoke a non-function object, no way to avoid that.
  • __set_state(): There's nothing like var_export() in JavaScript.
  • __clone(): There's no builtin cloning functionality in JavaScript that can be hooked into.
  • __debugInfo(): There's no way to hook into console.log() output.

Can I extend a class with Magic Methods on it?

Yes, to a certain extent:

class Bar extends Foo {}

// Or, if class Bar contains Magic Methods itself:

const Bar = magicMethods(class Bar extends Foo {
  // ...
})

Unfortunately though, you cannot access properties from the child class in the parent class:

const Foo = magicMethods(class Foo {
  __get() {
    return this.bar()
  }
})

class Bar extends Foo {
  bar() {
    return 'value'
  }
}

// This will *not* call B's bar() method but instead throw a TypeError:
(new Bar).something
function magicMethods (clazz) {
// A toggle switch for the __isset method
// Needed to control "prop in instance" inside of getters
let issetEnabled = true
const classHandler = Object.create(null)
// Trap for class instantiation
classHandler.construct = (target, args) => {
// Wrapped class instance
const instance = new clazz(...args)
// Instance traps
const instanceHandler = Object.create(null)
// __get()
// Catches "instance.property"
const get = Object.getOwnPropertyDescriptor(clazz.prototype, '__get')
if (get) {
instanceHandler.get = (target, name) => {
// We need to turn off the __isset() trap for the moment to establish compatibility with PHP behaviour
// PHP's __get() method doesn't care about its own __isset() method, so neither should we
issetEnabled = false
const exists = name in target
issetEnabled = true
if (exists) {
return target[name]
} else {
return get.value.call(target, name)
}
}
}
// __set()
// Catches "instance.property = ..."
const set = Object.getOwnPropertyDescriptor(clazz.prototype, '__set')
if (set) {
instanceHandler.set = (target, name, value) => {
if (name in target) {
target[name] = value
} else {
return target.__set.call(target, name, value)
}
}
}
// __isset()
// Catches "'property' in instance"
const isset = Object.getOwnPropertyDescriptor(clazz.prototype, '__isset')
if (isset) {
instanceHandler.has = (target, name) => {
if (!issetEnabled) return name in target
return isset.value.call(target, name)
}
}
// __unset()
// Catches "delete instance.property"
const unset = Object.getOwnPropertyDescriptor(clazz.prototype, '__unset')
if (unset) {
instanceHandler.deleteProperty = (target, name) => {
return unset.value.call(target, name)
}
}
return new Proxy(instance, instanceHandler)
}
// __getStatic()
// Catches "class.property"
if (Object.getOwnPropertyDescriptor(clazz, '__getStatic')) {
classHandler.get = (target, name, receiver) => {
if (name in target) {
return target[name]
} else {
return target.__getStatic.call(receiver, name)
}
}
}
// __setStatic()
// Catches "class.property = ..."
if (Object.getOwnPropertyDescriptor(clazz, '__setStatic')) {
classHandler.set = (target, name, value, receiver) => {
if (name in target) {
return target[name]
} else {
return target.__setStatic.call(receiver, name, value)
}
}
}
return new Proxy(clazz, classHandler)
}
@Fuzzyma

This comment has been minimized.

Copy link

commented Jun 19, 2018

You wrote that invoke cant be supported but there is the apply-hook. Doesnt that work? Beside that: Nice work!

@Fuzzyma

This comment has been minimized.

Copy link

commented Jun 23, 2018

I am currently using this script and it works like a charm. Thank you so much. However, I found ONE drawback:

When you catch a __getStatic and relay it to a new instance, the __get is not called because you access the target directly instead of teh Proxy.
Example:

class foo {
  __getStatic () {
    // create a new instance of foo
    let instance = new this
    
    // get back a random key which is not defined on the instance (which should trigger __get)
    return instance.a
  }

  __get () {
    // never called
    return 'baz'
  }
}

foo.bla // returns undefined instead of 'baz'

I fixed this problem!! :)

The handlers are often getting another value. The receiver. This parameter holds the proxy itself and you can simply use it to pull the desired value:

    const get = Object.getOwnPropertyDescriptor(clazz.prototype, '__get')
    if (get) {
      instanceHandler.get = (target, name, receiver) => {
        // We need to turn off the __isset() trap for the moment to establish compatibility with PHP behaviour
        // PHP's __get() method doesn't care about its own __isset() method, so neither should we
        issetEnabled = false
        const exists = name in target
        issetEnabled = true
        
        if (exists) {
          return target[name]
        } else {
          return receiver.__get(name)
        }
      }
}

What do you think?

@loilo

This comment has been minimized.

Copy link
Owner Author

commented Apr 1, 2019

Sorry for coming back at you so late — seems that GitHub doesn't offer notifications for Gist comments...

To come back to your points:

  1. No, the apply hook would not work here. The problem is that, if applied to a non-function, the apply trap will have no effect.

    That means that this will still throw a TypeError:

    (new Proxy(nonFunction, {
      apply() {
        return 'bar'
      }
    }))()
  2. You're absolutely correct. I didn't grasp the importance of receiver just until recently and have updated the above code accordingly. It should work now with your example (given that you'll wrap it in magicMethods() and use an actual static method.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.