Proposal: Default object method

# Brasten Sager (3 months ago)

Apologies if this has been raised before. I was unable to locate anything similar.

Any thoughts or ideas on this proposal would be appreciated!

Original: gist.github.com/brasten/f87b9bb470973dd5ee9de0760f1c81c7, gist.github.com/brasten/f87b9bb470973dd5ee9de0760f1c81c7

-Brasten

Proposal: Default object method

Objects w/ default method can be invoked like a function.

Problem

Objects that are well constrained (single responsibility) can tend to end up with a single method, or at least a single method that is important to most consumers. These methods tend to be named by either verbing the class name (eg. UserCreator.create()) or with some generic handle / perform / doTheObviousThing.

Whatever the name, downstream consumers of the object end up coupled to two implementation details:

  1. this thing-doer is an object and not a function
  2. this thing-doer's doing method is called X

Example

Here we are going to create an object that can be used to create a user later. Note that downstream consumers will only care that this object does one thing: create a user. While it make have other methods eventually for use in some limited contexts, creating a user is its primary (and often sole-) responsibility.

class UserCreator {
  constructor(repository) {
    this.repository = repository; 
  }

  create(name) {
     return this.repository.createUser(name);
  }  
}

const userCreator = new UserCreator(userRepository);

At this point, the userCreator is just a single-method object. It is useful for injecting into other objects that may need to create a user. But the fact that the userCreator is an object with a single useful method is an implementation detail to which consumers become coupled.


// Consumer of `userCreator`. Although this could itself be a
// good example of a "UserCreator"-like object (due to `.handle()`).
//
class UserSignupHandler {
  constructor(userCreator) {
    this.userCreator = userCreator;
  }
  
  handle(userName) {
    // UserSignupHandler is aware of ".create" when it really doesn't have to be.
    //
    return this.userCreator.create(userName);
  }
}

const handler = new UserSignupHandler(userCreator);

Notably, if we were to change the implementation of UserCreator later to be a pure function, we would have to change all consumers of UserCreator when conceptually it shouldn't be needed. There is still a thing-doer that has the same input/output.

Proposed Solution

An object instance can have a default method. This would allow an object to be "invoked" exactly like a function, hiding the implementation detail from consumers.

Note that there are several ways to define how the default method is determined, and this proposal is less concerned with this aspect than with what it looks like to invoke the object. We will demonstrate an option here, but alternatives are welcome.

// This particular implementataion would use a Symbol.
//

class UserCreator {
  constructor(repository) {
    this.repository = repository; 
  }

  [Symbol.apply](name) {
     return this.repository.createUser(name);
  }  
}

const userCreator = new UserCreator(userRepository);

class UserSignupHandler {
  constructor(userCreator) {
    // NOTE: at the consumer, it almost makes more sense to
    // name these with action verbs, as is done here.
    //
    this.createUser = userCreator;
  }
  
  handle(userName) {
    // UserSignupHandler is no longer coupled to the implementation details it doesn't need.
    //
    return this.createUser(userName);
  }
}

const handler = new UserSignupHandler(userCreator);
# Jordan Harband (3 months ago)

Something that can be invoked has a [[Call]] slot, and is typeof "function".

Adding a Symbol that makes something callable would have a number of effects - it would make typeof (one of the most robust operations in the language) unsafe, because it would have to access the Symbol method, which could be a throwing getter (or even one that just logs how many typeofs are called on it). Additionally, it would mean any object could become callable, and any function could be made un callable.

This seems like a pretty large change, solely to avoid "classes with a single method", which arguably should just be a function in the first place.

# Ranando King (3 months ago)

Jordan's right. This one is best handled by a function. But if there is some reason you need to create callable objects, it's still doable, even with ES as-is. Just extend your classes from something like this:

class Callable {
   constructor(defaultFn) {
      return (...args) => { return defaultFn.call(this, ...args); };
   }
}

Any class extending this will have instances that are functions. So using your UserCreator class...

class UserCreator extends Callable {
  constructor(repository) {
    super(this.create);
    this.repository = repository;
  }

  create(name) {
     return this.repository.createUser(name);
  }
}

Now new UserCreator(someRepo)(someName) is the same as new UserCreator(someRepo).create(someName).

# Ron Buckton (3 months ago)

There’s nothing in that proposal says that an object with a Symbol.apply has to have a different typeof. It would mean that any Call might require additional dispatch which could have performance implications. It could also be an approach to support “callable” classes:

class Foo {
  constructor() { /* constructor behavior */ }
  static [Symbol.apply]() { /* call behavior */ }
}

From: es-discuss <es-discuss-bounces at mozilla.org> On Behalf Of Jordan Harband

Sent: Sunday, January 27, 2019 9:35 PM To: Brasten Sager <brasten at brasten.me>

Cc: es-discuss <es-discuss at mozilla.org>

Subject: Re: Proposal: Default object method

Something that can be invoked has a [[Call]] slot, and is typeof "function".

Adding a Symbol that makes something callable would have a number of effects - it would make typeof (one of the most robust operations in the language) unsafe, because it would have to access the Symbol method, which could be a throwing getter (or even one that just logs how many typeofs are called on it). Additionally, it would mean any object could become callable, and any function could be made un callable.

This seems like a pretty large change, solely to avoid "classes with a single method", which arguably should just be a function in the first place.

On Sun, Jan 27, 2019 at 4:05 PM Brasten Sager <brasten at brasten.me<mailto:brasten at brasten.me>> wrote:

Apologies if this has been raised before. I was unable to locate anything similar.

Any thoughts or ideas on this proposal would be appreciated!

Original: gist.github.com/brasten/f87b9bb470973dd5ee9de0760f1c81c7nam06.safelinks.protection.outlook.com/?url=https%3A%2F%2Fgist.github.com%2Fbrasten%2Ff87b9bb470973dd5ee9de0760f1c81c7&data=02|01|ron.buckton%40microsoft.com|8c644033cebc48cdb58708d684e27a3d|72f988bf86f141af91ab2d7cd011db47|1|0|636842505603533257&sdata=qHxOcUdMF1i1QPc%2BZKC96Qq4%2BjQY1cgo7GBwDQ5f64Y%3D&reserved=0

-Brasten

Proposal: Default object method

Objects w/ default method can be invoked like a function.

Problem

Objects that are well constrained (single responsibility) can tend to end up with a single method, or at least a single method that is important to most consumers. These methods tend to be named by either verbing the class name (eg. UserCreator.create()) or with some generic handle / perform / doTheObviousThing.

Whatever the name, downstream consumers of the object end up coupled to two implementation details:

  1. this thing-doer is an object and not a function
  2. this thing-doer's doing method is called X

Example

Here we are going to create an object that can be used to create a user later. Note that downstream consumers will only care that this object does one thing: create a user. While it make have other methods eventually for use in some limited contexts, creating a user is its primary (and often sole-) responsibility.

class UserCreator {
  constructor(repository) {
    this.repository = repository;
  }

  create(name) {
     return this.repository.createUser(name);
  }
}

const userCreator = new UserCreator(userRepository);

At this point, the userCreator is just a single-method object. It is useful for injecting into other objects that may need to create a user. But the fact that the userCreator is an object with a single useful method is an implementation detail to which consumers become coupled.


// Consumer of `userCreator`. Although this could itself be a
// good example of a "UserCreator"-like object (due to `.handle()`).
//
class UserSignupHandler {
  constructor(userCreator) {
    this.userCreator = userCreator;
  }

  handle(userName) {
    // UserSignupHandler is aware of ".create" when it really doesn't have to be.
    //
    return this.userCreator.create(userName);
  }
}

const handler = new UserSignupHandler(userCreator);

Notably, if we were to change the implementation of UserCreator later to be a pure function, we would have to change all consumers of UserCreator when conceptually it shouldn't be needed. There is still a thing-doer that has the same input/output.

Proposed Solution

An object instance can have a default method. This would allow an object to be "invoked" exactly like a function, hiding the implementation detail from consumers.

Note that there are several ways to define how the default method is determined, and this proposal is less concerned with this aspect than with what it looks like to invoke the object. We will demonstrate an option here, but alternatives are welcome.

// This particular implementataion would use a Symbol.
//

class UserCreator {
  constructor(repository) {
    this.repository = repository;
  }

  [Symbol.apply](name) {
     return this.repository.createUser(name);
  }
}

const userCreator = new UserCreator(userRepository);

class UserSignupHandler {
  constructor(userCreator) {
    // NOTE: at the consumer, it almost makes more sense to
    // name these with action verbs, as is done here.
    //
    this.createUser = userCreator;
  }

  handle(userName) {
    // UserSignupHandler is no longer coupled to the implementation details it doesn't need.
    //
    return this.createUser(userName);
  }
}

const handler = new UserSignupHandler(userCreator);
# Isiah Meadows (3 months ago)

Nit: you may need to reset the function's prototype. Also, create should probably be static. ;-)

# J Decker (3 months ago)

On Sun, Jan 27, 2019 at 10:46 PM Ranando King <kingmph at gmail.com> wrote:

Jordan's right. This one is best handled by a function. But if there is some reason you need to create callable objects, it's still doable, even with ES as-is. Just extend your classes from something like this:

class Callable {
   constructor(defaultFn) {
      return (...args) => { return defaultFn.call(this, ...args); };
   }
}

Any class extending this will have instances that are functions. So using your UserCreator class...

class UserCreator extends Callable {
  constructor(repository) {
    super(this.create);
    this.repository = repository;
  }

  create(name) {
     return this.repository.createUser(name);
  }
}

Can't you just use a function object?

function UserCreator(repository) { if( !(this instanceof UserCreator ) ) return new UserCreator(repostiory); var creator = function xyz(name) { return { repo: repository, user: name } }: creator.repository = repository; creator.create = creator return creator; }

Now new UserCreator(someRepo)(someName) is the same as new UserCreator(someRepo).create(someName).

both of the above also work that way.

# Ranando King (3 months ago)

Isiah: good find. I was just being sloppy to get the idea across.

J. Decker: The code I gave was just an example of how to do it if it was for some reason absolutely necessary to use class. I don't see the need and would either tend toward your approach, or just somehow refactor away the need for the odd construction.

# Brasten Sager (3 months ago)

Thank you all so much for the feedback! The push-back has given me something to more to consider.

I’d like to add a couple points which are hopefully clarifying, but I do acknowledge the idea doesn’t seem to be attracting positive interest in its current form.

I meant to note in the original gist that this isn’t a unique idea. Scala (and possibly others) do something very similar. The apply symbol was chosen for this example based on the name of the apply method from Scala. However, not being a Scala developer myself, I am cautious about accidentally attaching any baggage that may exist (unknown to me) in Scala’s solution to this proposal.

To Jordan’s points - I would hope that the proposal could be scoped such that the impacts mentioned were not so significant. It certainly wasn’t the intent to make functions un-callable, for example. I would need to understand ES details a bit better to fully work through the objections raised (though this has motivated me to do so!).

The proposal provides a very tactical example and one that I run into all the time (but perhaps I’m in a minority there). I should add a more strategic motivation to the proposal. The broader intent was to allow even better interaction between the functional and OO coding styles are common in ES. A function and an object are clearly not interchangeable in every situation. However there are many situations where they could be - conceptually. The proposal would help facilitate that.

Lastly, regarding other ways to accomplish this, I usually end up expecting a function in the consumer. Dependencies then either need to be written as functions (usually higher-order functions to account for dependency injection), or wrapped by a function when provided to a consumer. So, something like this:

// class-based
const userCreator = new UserCreator(userRepository);

class UserSignupHandler {
  constructor(createUserFn) {
    this.createUser = createUserFn;
  }
  
  handle(userName) {
    return this.createUser(userName);
  }
}

const handler = new UserSignupHandler(
  u => userCreator.create(u)
);

This is not too bad, syntactically, and gets ever better if the bind-operator proposal goes through. But the frequency with which I find myself doing led to this proposal.

I’m happy to hear any additional feedback others may have.

# Brasten Sager (3 months ago)

Yeah! The static version of this is definitely something I see as a natural next step.

From my observation, tc39 proposals seem to take on the instance and static versions of a feature separately. So for that reason it wasn’t mentioned.

But yes, if this proposal somehow made it through the process it would be almost tragic if a static version did not immediately follow. :-)