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

Fuzzyma 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

Fuzzyma 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

loilo 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.)

@yellow1912

This comment has been minimized.

Copy link

yellow1912 commented Oct 2, 2019

You mentioned that __get can be used just like __call, but I wonder how you can get the arguments passed into the magic __get? Right now it seems like inside get you only have access to the name variable.

Edit: I was using arrow function which was why I didn't receive the expected arguments.

@loilo

This comment has been minimized.

Copy link
Owner Author

loilo commented Oct 2, 2019

You cannot directly use __get like __call, but you can work around it.

In PHP, calling a method $foo->bar() is different from accessing a property $foo->bar, which is why __get and __call can be distinguished.

In JavaScript however, calling a method foo.bar() actually just fetches the bar property, assumes that it's a function and calls it. In PHP terms, a method call in JavaScript is a __get, followed by an __invoke on its return value. The PHP equivalent would be this:

class Foo
{
    public function __get($name)
    {
        if ($name === 'bar') {
            return function(...$args) {
                echo 'Hello world!';
            };
        }
    }
}

$foo = new Foo();

// $foo->bar() is a method call and there is no "bar" method, so this fails:
$foo->bar();

// But the $foo->bar property is an actual function and can be invoked, so this works:
($foo->bar)();
// or:
$bar = $foo->bar;
$bar();

So in JavaScript, you don't actually acces the arguments in the __get method itself, but from the __get method you return a function which receives arguments.

This means to access the arguments when calling foo.bar(1, 2, 3), you would do this:

const Foo = magicMethods(class Foo {
  __get (name) {
    if (name === 'bar') {
      // User wants to call the `bar` method, return a function
      return (...args) => {
        // This is the code actually executed when calling foo.bar(),
        // so you have access to the passed arguments here
      }
    }
  }
})

By the way: Since calling a method in JavaScript just means plucking the method property from the object and then invoking it, you actually cannot overload the same name as a property access and a method call (which works in PHP).

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.