Dan7el
Dan7el

Reputation: 2057

TypeScript Dynamically or Programmatically Chain Functions

TypeScript function chaining, but I want to programmatically chain them.

Example class: chain.ts

class MyChain {
  value: number = 0;
  constructor() {
    this.value = 0;
  }

  sum(args: number[]) {
    this.value = args.reduce((s, c) => s + c, 0);
    return this;
  }

  add(v: number) {
    this.value = this.value + v;
    return this;
  }

  subtract(v: number) {
    this.value = this.value - v;
    return this;
  }
}

const mc = new MyChain();
console.log(mc.sum([1, 2, 3, 4]).subtract(5).value);

I see the number 5 on the console.

Now, I'm still fairly new to JavaScript and TypeScript, so I figured out that the function within this class is actually an element of an array of the instance of the class. Hence, I can do this:

console.log(mc["sum"]([1, 2, 3, 4]).value);

This indeed returns the number 10.

Now, I'm confused as to how I'd chain this programmatically. For example (this is obviously not what I would want to do anyway and shows my boneheaded lack of understanding of JavaScript:

console.log(mc["sum"]([1, 2, 3, 4]).mc["subtract"](5).value);

Error:

Property 'mc' does not exist on type 'MyChain'.ts(2339)

Okay, in all honesty, I kind of intuitively knew that wasn't going to work. However, thinking about it, how would I go about accessing the elements of a multidimensional array in just about any reasonable language?

console.log(mc["sum"]([1, 2, 3, 4])["subtract"](5).value);

Bingo. This does the trick. But, this isn't really the solution I need. What I need is something like this:

interface IChainObject {
  action: string;
  operand: number | number[];
}

const chainObj: IChainObject[] = [
  { action: "sum", operand: [1, 2, 3, 4] },
  { action: "subtract", operand: 5 },
];

And, to start, I'd like to try this:

console.log(mc[chainObj[0].action](chainObj[0].operand).value);

And consequently, generating a mechanism that would ultimately build something like this:

console.log(
  mc[chainObj[0].action](chainObj[0].operand)[chainObj[1].action](
    chainObj[1].operand
  ).value
);

Hence, it seems to me that what I want is some way to generate this:

[chainObj[0].action](chainObj[0].operand)[chainObj[1].action](chainObj[1].operand)

from this, with my chain object having one or many action/operand object sets:

const chainObj: IChainObject[] = [
  { action: "sum", operand: [1, 2, 3, 4] },
  { action: "subtract", operand: 5 },
];

Now, this is where my brain more or less shuts down. I am thinking that I need to generate a chain of string values, but they'll just be strings and won't really work as array indexes into the function as I want.

Why do I want to do this? Ultimately, I want to build a complex Yup schema object from a JSON object. I found this excellent post, but my core issue is I don't really understand how this code works.

At this point, I am able to parse out the way Vijay was able to solve his issue and mimic it, in a way. Here's working code for my example:

const mc = new MyChain();

interface IChainObject {
  action: string;
  operand: number | number[];
}

const chainObj: IChainObject[] = [
  { action: "sum", operand: [1, 2, 3, 4, 5] },
  { action: "subtract", operand: 5 },
];

let myChain = {};
chainObj.forEach((o) => {
  myChain = mc[o.action](o.operand);
});
console.log("myChain is", myChain["value"]);

Results in: myChain is 10

You're probably asking yourself, "What's your problem Dan?. You seem to have a solution in hand now." Yes, I guess I do, but I don't understand it. I'm basically copying and pasting code, marginally understanding it, and making changes that make it work.

My basic issue is I don't understand how this line of code works: myChain = mc[o.action](o.operand);

I get the general gist that it's calling the function based on the action and providing the data to the function via the operand. I'm a copy and paste code monkey. I want to be more than a monkey. Maybe a baboon or even ape. Hence, I want to understand what I've done. What doesn't make sense to me is how it's chaining it.

I thought maybe the secret was in the forEach function, but that doesn't seem to be it. Here is a simple test:

let p = 0;
const x = [1, 2, 3, 4];
x.forEach((y) => {
  p = y;
});
console.log("p is", p);  p is 4

What is the secret JavaScript magic that is happening under the hood that makes the myChain = mc[o.action](o.operand); code actually chain my functions together rather than simply work one and the work the other. I'm just not seeing it.

Upvotes: 1

Views: 2066

Answers (3)

jcalz
jcalz

Reputation: 330571

There are so many pieces of this; I'm just going to focus on "what's the secret that makes the forEach() code work?"

The "secret" is that instances of MyChain have a property named value that gets updated after each method is called. The code with forEach() is not really chaining calls together; it just operates on the original MyChain variable named mc each time.

Since all the methods of MyChain that update this.value also return this, it happens not to matter whether you really chain calls (operate on the return value of each method call):

const chaining = new MyChain();
console.log(chaining.add(3).subtract(1).value); // 2

or if you just call methods on the original object in succession:

const notChaining = new MyChain();
notChaining.add(3);
notChaining.subtract(1);
console.log(notChaining.value) // 2

If you want there to be a difference between those, you can show it by making two versions of MyChain; one that only works via chaining, and one that only works in succession.

The following requires chaining because it never updates the original object and method calls return new objects with the results of the method call:

class RealChain {
  constructor(public value: number = 0) { }

  sum(args: number[]) {
    return new RealChain(args.reduce((s, c) => s + c, 0));
  }

  add(v: number) {
    return new RealChain(this.value + v);
  }

  subtract(v: number) {
    return new RealChain(this.value - v);
  }
}
    
const realChaining = new RealChain();
console.log(realChaining.add(3).subtract(1).value); // 2

const notRealChaining = new RealChain();
notRealChaining.add(3);
notRealChaining.subtract(1);
console.log(notRealChaining.value) // 0

and the following prohibits chaining, because it only updates the original object and its methods don't return anything:

class NotChain {
  value: number = 0;
  constructor() {
    this.value = 0;
  }

  sum(args: number[]) {
    this.value = args.reduce((s, c) => s + c, 0);
  }

  add(v: number) {
    this.value = this.value + v;
  }

  subtract(v: number) {
    this.value = this.value - v;
  }
}

const realNotChaining = new NotChain();
realNotChaining.add(3);
realNotChaining.subtract(1);
console.log(realNotChaining.value) // 2

const badNotChaining = new NotChain();
console.log(badNotChaining.add(3).subtract(1).value); // error! 
// badNotChaining.add(3) is undefined so you can't call subtract() on it

The code with forEach() would only work with NotChain instances and not with RealChain instances.


If you want a programmatic loop-like thing that actually works with chaining and not calling methods on an original object, you should probably use reduce() instead of forEach():

const realChainReduced = chainObj.reduce(
  (mc, o) => mc[o.action](o.operand), 
  new RealChain() // or MyChain, doesn't matter
);
console.log("realChainReduced is", realChainReduced.value); // 10

Note that I didn't cover any of the other parts, including TypeScript specifics (the typings used here give some compiler errors), so be warned.

Playground link to code

Upvotes: 0

Dane Brouwer
Dane Brouwer

Reputation: 2972

Most things in Javascript, like classes are basically just objects.1

What that means is that attributes, or in this case - functions, can be accessed via the dot notation or bracket notation.

Lets look at an example that might assist the explanation:

class MyClass {
  myFunction(x) {
    console.log(x);
  }
}
const x = new MyClass();
// attribute accessed via the dot notation
x.myFunction("Hello World!");
// attribute accessed via the bracket notation and a string 
x['myFunction']("Hello World, again!");
// attribute accessed via a variable that is a string 
const functionName = 'myFunction';
x[functionName]("Well uh, Hello World again?");
// attribute accessed via a variable that is a string, and passing in an argument
const argument = "This is " + "an argument";
x[functionName](argument);

To illustrate the point further:

class MyClass {
  myFunction(x) {
    console.log(x);
  }
}
const x = new MyClass();
console.log(x.myFunction) // returns a function
console.log(x["myFunction"]) // returns a function

// executing the function
x.myFunction("Method One");
x["myFunction"]("Method Two")

We can see that the returned function can be called.

So let's get back to your example

chainObj.forEach((o) => {
  myChain = mc[o.action](o.operand);
});
  • o.action is the function name
  • o.operand is the argument Therefore, what is roughly translates to is:
chainObj.forEach((o) => {
  myChain = mc[functionName](arugment);
});

just like our previous examples.

1 "classes are basically just objects"

Upvotes: 0

Aplet123
Aplet123

Reputation: 35560

Let's start from the first misunderstanding I can find:

Now, I'm still fairly new to JavaScript and TypeScript, so I figured out that the function within this class is actually an element of an array of the instance of the class.

This is not the case. Square brackets in Javascript are used for all property lookups, not just array indexing. x.foo is actually equivalent to x["foo"], and the same syntax works for arrays since arrays are just objects. Classes in Javascript are just objects that have a prototype property, which is itself an object. It contains a list of default attributes, and if you instantiate a class and look up a property that isn't in the object, it'll search for it in the prototype. So, looking at the code:

mc["sum"]([1, 2, 3])

It searches for a "sum" property in mc, and can't find any since you haven't defined one, so it searches in the prototype of MyChain, and finds the mc method. Thus, mc["sum"] is the sum method of mc. Now, this code:

console.log(mc["sum"]([1, 2, 3, 4]).mc["subtract"](5).value);

doesn't work, and it looks very off for a reason. mc["sum"]([1, 2, 3, 4]) returns mc, so why would you have to access the mc property (not that the mc property even exists)? That's why your second example, the one that calls subtract directly, works:

console.log(mc["sum"]([1, 2, 3, 4])["subtract"](5).value);

Now, let's look at the working code:

const mc = new MyChain();

interface IChainObject {
  action: string;
  operand: number | number[];
}

const chainObj: IChainObject[] = [
  { action: "sum", operand: [1, 2, 3, 4, 5] },
  { action: "subtract", operand: 5 },
];

let myChain = {};
chainObj.forEach((o) => {
  myChain = mc[o.action](o.operand);
});
console.log("myChain is", myChain["value"]);

You actually don't need a lot of this code. It can be simplified down to:

const mc = new MyChain();

interface IChainObject {
  action: keyof MyChain;
  operand: number | number[];
}

const chainObj: IChainObject[] = [
  { action: "sum", operand: [1, 2, 3, 4, 5] },
  { action: "subtract", operand: 5 },
];

chainObj.forEach((o) => {
  // bypass typescript type checking with cast
  (mc[o.action] as Function)(o.operand);
});
console.log("myChain is", mc.value);

Essentially, the forEach loops through the elements in chainObj in order. The element's value is stored in the variable o. mc[o.action] takes the method name stored in o.action, and accesses it using square brackets. This is basically looking up the method. Then, the method is called with (o.operand) (in Javascript functions are just values, and you can call any value like a function, but if it's not a function it'll error). mc then modifies itself, and you move on to the next loop. If we insert a debugger statement in the function then break on the first loop, we can inspect the variables:

first loop

As you can see, the value starts off at 0, o.action is "sum", and mc[o.action] is the sum method. We can then call the sum method with o.operand, which adds the elements up and sets the value to 15. Then, in the second loop:

second loop

mc[o.action] is the subtract method, and we call it with o.operand, which is 5, lowering the value to 10.

Upvotes: 2

Related Questions