Skip to content

Instantly share code, notes, and snippets.

@loilo
Last active April 25, 2024 13:58
Show Gist options
  • Star 48 You must be signed in to star a gist
  • Fork 14 You must be signed in to fork a gist
  • Save loilo/4d385d64e2b8552dcc12a0f5126b6df8 to your computer and use it in GitHub Desktop.
Save loilo/4d385d64e2b8552dcc12a0f5126b6df8 to your computer and use it in GitHub Desktop.
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(): Functions are first-class objects in JavaScript. That means that (as opposed to PHP) an object's methods are just regular properties in JavaScript and must first be obtained via __get() to be invoked subsequently. So to implement __call() in JavaScript, you'd simply have to implement __get() and return a function from there.
  • __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:

// `Bar` inherits magic methods from `Foo`
class Bar extends Foo {}

Or, if class Bar contains magic methods itself:

const Bar = magicMethods(class Bar extends Foo {
  // You may define `Bar`'s magic methods here
})
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, receiver) => {
// Wrapped class instance
const instance = Reflect.construct(target, args, receiver)
// Instance traps
const instanceHandler = Object.create(null)
// __get()
// Catches "instance.property"
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 = Reflect.has(target, name)
issetEnabled = true
if (exists) {
return Reflect.get(target, name, receiver)
} 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, receiver) => {
if (name in target) {
Reflect.set(target, name, value, receiver)
} 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 Reflect.has(target, name)
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
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
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
Copy link
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
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
Copy link
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).

@loilo
Copy link
Author

loilo commented Sep 21, 2020

Side note: I was able to remove the main constraint (not being able to access child props in parent magic methods) from the implementation by instantiating objects using Reflect.construct(target, args, receiver) instead of new target(...args).

@yellow1912
Copy link

Thank you for the new update. Updated my code accordingly.

@axmad386
Copy link

axmad386 commented Jan 7, 2022

Note that Object.getOwnPropertyDescriptor wont work if target is child class.
For example

class A {
    static _getStatic(){
    ....
    }
}

class B extends A {}

when I wrap class B with magicMethod it will break.
For workaround, I use Object.getPropertyOf(childClass)['method'] to check the method is exist on parent class
so the magic methods wil be

// __getStatic()
  // Catches "class.property"
  if (Object.getPropertyOf(clazz)['__getStatic']) {
    classHandler.get = (target, name, receiver) => {
      if (name in target) {
        return target[name]
      } else {
        return target.__getStatic.call(receiver, name)
      }
    }
  }

Btw thanks for this awesome gist 😃 👍

@a-khawaldeh
Copy link

can I override magic methods in child classes, or I should wrap the child class with magic methods again?

@loilo
Copy link
Author

loilo commented Mar 28, 2022

@a-khawaldeh You'll need to wrap any class with the magicMethods() function where you want to define magic methods, even if it extends an already existing magic methods class.

@axmad386
Copy link

@loilo I noticed something. When we declare some property without initial value, the __get method will have unexpected behaviour.
for example

@magicMethods
class A {
    protected title?:string;
    public setTitle(title: string){
       this.title = title;
    }
}

This is because Reflect.has(target, name) will return false, then get.value.call(target, name) will called. So we should initialize all property with something, for example null.

@magicMethods
class A {
    protected title:string|null=null;
    public setTitle(title: string){
       this.title = title;
    }
}

CMIIW

@loilo
Copy link
Author

loilo commented Apr 12, 2022

@axmad386 This is not unexpected if you look at the compiled output of your TypeScript code: The uninitialized title is compiled away completely, therefore it actually doesn't exist if Reflect.has() checks for it. See here.

That said, a pure JavaScript declaration of a property with no value does work:

class A {
    title
}

// etc.

So to get as close to the original JS behavior as possible, you may want to initialize your property with undefined:

@magicMethods
class A {
    protected title?:string = undefined;
    public setTitle(title: string){
       this.title = title;
    }
}

@axmad386
Copy link

oh I see, thanks @loilo. Initialize property to undefined is a way better 👍

@zymawy
Copy link

zymawy commented Apr 15, 2022

how I can get the args being passed to the functions

class A {
	    __get (name, args) {
        if (args && this.elementCreator.hasOwnProperty(name)) {
            this.elementCreator.{name}(args);
        }

        throw new Error('Underpin Property')
    }
}

so I wanna forwards calls to underline object just like ForwardsCalls in laravel

a alongside with it's args,

@loilo
Copy link
Author

loilo commented Apr 16, 2022

Hey @zymawy,

you cannot exactly reproduce the ForwardsCalls behavior because JavaScript does not differentiate between properties and methods (methods are just properties that also happen to be functions).
So you can not differentiate between just accessing a property (__get) and calling a method (__call).

However, if you only have methods to forward, what you can do is to return a function from __get:

class A {
  __get(name) {
    return (...args) => {
      if (this.elementCreator.hasOwnProperty(name)) {
        return this.elementCreator[name](...args)
      }

      throw new Error('Underpin Property')
    }
  }
}

@hakito
Copy link

hakito commented Aug 16, 2022

@loilo This works great. Under which license do you provide the code?

@loilo
Copy link
Author

loilo commented Aug 16, 2022

Good point @hakito. I used the opportunity to clarify this here.

@ohjay666
Copy link

ohjay666 commented Nov 17, 2023

Hi @loilo,
it looks like the magic only works from the outside and not within methods defined in the constructor or the prototype including the __get method itself because 'this' points to the instance and not to the instanceProxy. did i make a mistake in implementing or is this the behaviour? I helped myself setting this.proxy after cerating the object ... but thats not the idea i think ... example:

var clazz = function(){
  this.foo = () => {
    return this.unknown;
  }
}

clazz.prototype.__get = function(name){
  return 'buz';
}

clazz.prototype.bar = () => this.unknown;

var magicClazz = magicMethods(clazz);
var obj = new magicClazz();
console.log(obj.unknown); // buz
console.log(obj.foo()); // undefined
console.log(obj.bar()); // undefined

Edit: the problem seems to be the use of arrow functions ... expected result here:

var clazz = function() {
  this.foo = function () {
    return this.unknown;
  }
}

clazz.prototype.__get = function(name) {
  return 'buz';
}

clazz.prototype.bar = function() {
  return this.unknown;
}

var magicClazz = magicMethods(clazz);
var obj = new magicClazz();
console.log(obj.unknown); // buz
console.log(obj.foo()); // buz
console.log(obj.bar()); // buz

do you see a chance to support arrow funcs as well?
thanks for providing this ... learned a lot today :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment