Skip to content

Instantly share code, notes, and snippets.

@loilo
Last active April 25, 2024 13:58
Show Gist options
  • 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)
}
@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