How to dynamically create a method with a variable number of parameters stored in a variable?

109 views Asked by At

In order to set up a Socket.IO client, I have a bunch of methods looking like this:

myobject.prototype.A = function (callback) {
    this.foo('a', null, callback);
}

myobject.prototype.B = function (bar, callback) {
    this.foo('b', { bar }, callback);
}

myobject.prototype.C = function (baz, qux, callback) {
    this.foo('c', { baz, qux }, callback);
}

The content of this.foo is unimportant, but it takes 3 parameters: a string, an object built from the calling method parameters, and a callback method.

I'd like to have the methods set up in a single place. I'd like to have something looking like this:

// I'm not sure what form the args should take
const methods = {
  A: { socketName: 'a', args: [ ] },
  B: { socketName: 'b', args: [ 'bar' ] },
  C: { socketName: 'c', args: [ 'baz', 'qux' ] }
};

for (let m in methods) {
    const mData = methods[m];
    this.prototype[m] = function (what_do_I_put_here_?, callback) {
        // how do I form "otherArgs" ?
        this.foo(mData.socketName, otherArgs, callback);
    }
}

I think I'll have to look to destructuring assignments but I'm not sure how to use them in this case.

3

There are 3 answers

0
t.niese On BEST ANSWER

You could do that utilizing closures and an array function:

"use strict"
class Test {

  createDynFoo(name, propertyNames = []) {
    // return an arrow function (ensures that this is still the obj)
    return (x, ...args) => {
      let obj = null; // set obj to null as default
      if (args.length > 0) {
        // if we have additional aguments to x we create an obj
        obj = {};
        // map the additional arguments to the property names
        propertyNames.forEach((value, idx) => {
          obj[value] = args[idx]
        })
      }
      // call the actual foo function
      return this.foo(name, x, obj)
    }
  }

  foo(name, x, obj) {
    console.log(`${name} ${x}`)
    console.dir(obj);
  }
}

let test = new Test();


let tiggerForA = test.createDynFoo('a');
let tiggerForB = test.createDynFoo('b', ['y']);
let tiggerForC = test.createDynFoo('c', ['y', 'z']);

tiggerForA(1);
tiggerForB(1, 2);
tiggerForC(1, 2, 3);

If you really need it as member functions you could do:

"use strict"
class Test {

  constructor() {
    this.A = this.createDynFoo('a');
    this.B = this.createDynFoo('b', ['y']);
    this.C = this.createDynFoo('c', ['y', 'z']);
  }


  createDynFoo(name, propertyNames = []) {
    // return an arrow function (ensures that this is still the obj)
    return (x, ...args) => {
      let obj = null; // set obj to null as default
      if (args.length > 0) {
        // if we have additional aguments to x we create an obj
        obj = {};
        // map the additional arguments to the property names
        propertyNames.forEach((value, idx) => {
          obj[value] = args[idx]
        })
      }
      // call the actual foo function
      return this.foo(name, x, obj)
    }
  }

  foo(name, x, obj) {
    console.log(`${name} ${x}`)
    console.dir(obj);
  }
}

let test = new Test();


test.A(1);
test.B(1, 2);
test.C(1, 2, 3);

If it is only about member functions you could get rid of the the arrow function like this:

"use strict"



function createDynFunction(classObj, targetFunctionName, newFunctionName, name, propertyNames = []) {
  // create a new function and assigned it to the prototype of the class
  classObj.prototype[newFunctionName] = function(x, ...args) {
    let obj = null; // set obj to null as default
    if (args.length > 0) {
      // if we have additional aguments to x we create an obj
      obj = {};
      // map the additional arguments to the property names
      propertyNames.forEach((value, idx) => {
        obj[value] = args[idx]
      })
    }
    // call the actual foo function
    return this[targetFunctionName](name, x, obj)
  }
}

class Test {
  foo(name, x, obj) {
    console.log(`${name} ${x}`)
    console.dir(obj);
  }
}

createDynFunction(Test, 'foo', 'A', 'a');
createDynFunction(Test, 'foo', 'B', 'b', ['y']);
createDynFunction(Test, 'foo', 'C', 'c', ['y', 'z']);

let test = new Test();

test.A(1);
test.B(1, 2);
test.C(1, 2, 3);

As I don't know the exact use case, it is hard to come up with a solution that exactly matches the needs. But based on the shown code, you should get an idea how this can be achieved.

0
VLAZ On

const toObj = (keys, values) =>
  keys.length === 0
    ? null
    : _.zipObject(keys, values); 

function wrap(fn, name, keys) {
  return function(...args) {
    const callback = args.pop();
    const obj = toObj(keys, args);
    
    return fn.call(this, name, obj, callback);
  }
}

class Foo {
  foo(name, obj, callback) { 
    //calling a method to show that the value of `this` is forwarded in the call
    console.log(this.bar(name, obj, callback));
  }
  
  bar(name, obj, callback) {
    return `name: '${name}'; obj: '${JSON.stringify(obj)}', callback: '${callback}'`;
  }
}

const methods = {
  A: { socketName: 'a', args: [ ] },
  B: { socketName: 'b', args: [ 'bar' ] },
  C: { socketName: 'c', args: [ 'baz', 'qux' ] },
};

for (let m in methods) {
    const mData = methods[m];
    Foo.prototype[m] = wrap(Foo.prototype.foo, mData.socketName, mData.args);
}

const foo = new Foo();

foo.A(() => "this is A's callback");
foo.B("hello", () => "this is B's callback");
foo.C("hello", "world", () => "this is C's callback");
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js"></script>

This utilises a higher order function that can wrap/decorate functions and enforce the needed parameters. Function.call would also set the value of this when calling, thus if decorating a method it will use the value of this where it is called from as usual:

/**
 * Wrap functions to ensure parameters are uniformly applied.
 *
 * @param {Function} fn - function to decorate
 * @param {string} name - first to be given to the function
 * @param {Array<string|number|Symbol>} keys - keys for the object which 
 * will become the second parameter of `fn`
 * 
 * @return {Function} function that will be called with variable number 
 * of arguments - the number of `keys` plus one callback. Forwards the 
 * value of `this` to the function.
 */
function wrap(fn, name, keys) {
  return function(...args) {
    const callback = args.pop();
    const obj = toObj(keys, args);
    
    return fn.call(this, name, obj, callback);
  }
}

There is the toObj which should take the keys and varargs, then produce an object out of them. I use a ready made implementation for brevity - _.zipObject() from Lodash. However, it can be custom changed or entirely custom built, if needed.

With that a call would look like

wrap(someFn, "b", [ 'bar' ]

which will produce a function that can be called like

wrappedFn("hello", () => "some callback")

which would in turn call

someFn("b", [ 'bar' ], () => "some callback")

const toObj = (keys, values) =>
  keys.length === 0
    ? null
    : _.zipObject(keys, values); 

function wrap(fn, name, keys) {
  return function(...args) {
    const callback = args.pop();
    const obj = toObj(keys, args);

    return fn.call(this, name, obj, callback);
  }
}

function someFn(name, obj, callback) {
  console.log(`name: '${name}'; obj: '${JSON.stringify(obj)}', callback: '${callback}'`);
}
const wrappedFn = wrap(someFn, "b", [ 'bar' ]);

wrappedFn("hello", () => "some callback");
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js"></script>

Two notable things:

  • the number of keys and values is not verified. If that is required, it needs to be handled
  • the length property of the wrapped function will report zero, as that is how the rest syntax for parameters is defined to work. This means that examining it to find the number of parameters before calling will be unreliable
0
Peter Seliger On

A possible approach's core feature would be making use of a single, argument-handling, this-context aware function and binding,

The actual parameters are getting fetched by the rest parameter syntax.

The expected callback function could be popped from the args array, which mutates the latter and leaves just the remaining parameter-values which need to be passed as values of a payload-like object where the object's keys are configured by the pre-bound paramNames array.

The payload gets created via a mapping task which creates an array of key-value pairs which then gets passed to Object.fromEntries.

The actual creation from a parameter configuration and the assignment of each created method to a target object can be achieved by reduceing the entries of such a config object.

function forwardWithBoundContextAndParamsConfig(
  socketName, paramNames, ...args
) {
  const callback =
    // ... ... ?? always assure at least a callback.
    args.pop() ?? (_ => _);

  const payload = (paramNames.length === 0)
    ? null
    : Object.fromEntries(
        paramNames.map((key, idx) => [key, args[idx] ])
      );

  // - forwarding upon the correct context
  //   and with the correct parameter data
  //   derieved from the bound configuration.
  return this.foo(socketName, payload, callback);
}

const methodConfig = {
  A: { socketName: 'a', paramNames: [] },
  B: { socketName: 'b', paramNames: ['bar'] },
  C: { socketName: 'c', paramNames: ['baz', 'qux'] },
};

class SocketTest {
  foo(socketName, payload, callback) {
    console.log({ socketName, payload, callback });
  }
}
Object
  .entries(methodConfig)
  .reduce((target, [methodName, { socketName, paramNames }]) => {

    target[methodName] = forwardWithBoundContextAndParamsConfig
      .bind(SocketTest.prototype, socketName, paramNames);

    return target;

  }, SocketTest.prototype);

console.log({
  SocketTest,
  'SocketTest.prototype': SocketTest.prototype,
});
const socketTest = new SocketTest;

socketTest.A();
socketTest.A(function callBack_A() {});

socketTest.B('quick', function callBack_B() {});
socketTest.C('brown', 'fox', function callBack_C() {});
.as-console-wrapper { min-height: 100%!important; top: 0; }