DEV Community

loading...
Cover image for Why you need the tap method

Why you need the tap method

aminnairi profile image Amin Updated on ・6 min read

Let's say you have a script that process an array with multiple transformations to finally compute your result.

"use strict";

const input = "732829320";

const valid =
    Array
        .from(input)
        .map(character => parseInt(input) || 0)
        .map((digit, index) => index % 2 === 0 ? digit : digit * 2)
        .map(digit => digit > 9 ? digit - 9 : digit)
        .reduce((sum, digit) => sum + digit) % 10 === 0;

console.log(valid);

As you can see, we are using a lot of Array methods to ease the development of our script that could have been really verbose if written without these methods. But still, there is a problem and impossible to know what is happening since I'm really tired and it is late night. The valid variable stores the false value, even if our input is valid.

By the way, I am here validating a SIREN number, which is a special enterprise identification number used in France to identify companies. You don't need to understand what is going on here but for those who are curious, it uses the Luhn algorithm, which is the same algorithm used to validate VISA's credit card numbers.

Maybe you tried something like that.

"use strict";

const input = "732829320";

const valid =
    Array
        .from(input)
        .forEach(character => console.log(character))
        .map(character => parseInt(input) || 0)
        // Error: cannot read property map of undefined
        .map((digit, index) => index % 2 === 0 ? digit : digit * 2)
        .map(digit => digit > 9 ? digit - 9 : digit)
        .reduce((sum, digit) => sum + digit) % 10 === 0;

console.log(valid);

WARNING: this is not production-ready code. Don't copy/paste it to solve this problem in your applications! It is just a pretext to give you an example of how the tap method can be used here.

Unfortunately, it won't work due to the fact that the forEach method will return undefined, meaning that it cannot be chained by others calls to map, filter, reduce, etc...

But we could store values for each steps and just log the output of each. That's a solution.

"use strict";

const input = "732829320";

const array = Array.from(input);

console.log(array);

const digits = array.map(character => parseInt(input) || 0);

console.log(digits);

const multiplied = digits.map((digit, index) => index % 2 === 0 ? digit : digit * 2)

console.log(multiplied);

const digitSum = multiplied.map(digit => digit > 9 ? digit - 9 : digit);

console.log(digitSum);

const sum = digitSum.reduce((sum, digit) => sum + digit);

console.log(sum);

const valid = sum % 10 === 0;

console.log(valid);

But it is really verbose, like a lot. And I had to come up with new names for my variables, which is something I wasted time since I will not use these except for the purpose of logging them.

But it works, and I finally managed to figure why I had an error. The second log for the digits variable gives me something like that:

[ 732829320,
  732829320,
  732829320,
  732829320,
  732829320,
  732829320,
  732829320,
  732829320,
  732829320 ]

Which is weird at first glance since I was expecting to turn all my characters to a single digit. But in reality, I am parsing the input variable, instead of the character variable. So here is my error. I found it and successfully validated my script.

"use strict";

const input = "732829320";

const valid =
    Array
        .from(input)
        .map(character => parseInt(character) || 0)
        .map((digit, index) => index % 2 === 0 ? digit : digit * 2)
        .map(digit => digit > 9 ? digit - 9 : digit)
        .reduce((sum, digit) => sum + digit) % 10 === 0;

console.log(valid);

But can we do better? Yes! By using a tap method. In a nutshell, and in this case, a tap method will help you loop through your array, without touching it, and will return it to be chained in others calls. If you didn't understand, that's okay. An example is worth a hundred words.

"use strict";

const input = "732829320";

const valid =
    Array
        .from(input)
        .tap(character => console.log(character))
        .map(character => parseInt(character) || 0)
        .map((digit, index) => index % 2 === 0 ? digit : digit * 2)
        .map(digit => digit > 9 ? digit - 9 : digit)
        .reduce((sum, digit) => sum + digit) % 10 === 0;

console.log(valid);

As you can see, we are using the tap method to log our characters, before they can be mapped to numbers in the next map call. All I did was to branch my tap method between those calls and tada, we got a logging of our data without even having to make a mess in our code. The tap method here will produce the following output.

7
3
2
8
2
9
3
2
0

And we can keep going and branch our tap method as much as we want since by definition, it will always return the same thing, meaning an array of data.

Let's be crazy and branch it everywhere.

"use strict";

const input = "732829320";

const valid =
    Array
        .from(input)
        .tap(character => console.log(character))
        .map(character => parseInt(character) || 0)
        .tap(character => console.log(character))
        .map((digit, index) => index % 2 === 0 ? digit : digit * 2)
        .tap(character => console.log(character))
        .map(digit => digit > 9 ? digit - 9 : digit)
        .tap(character => console.log(character))
        .reduce((sum, digit) => sum + digit) % 10 === 0;

console.log(valid);

Of course, this will log out many things, maybe not the best way to debug our code but it is an example of how far you could go with this method. And of course, you could shorten this call by passing console.log as a first-class function.

"use strict";

const input = "732829320";

const valid =
    Array
        .from(input)
        .tap(console.log)
        .map(character => parseInt(character) || 0)
        .map((digit, index) => index % 2 === 0 ? digit : digit * 2)
        .map(digit => digit > 9 ? digit - 9 : digit)
        .reduce((sum, digit) => sum + digit) % 10 === 0;

console.log(valid);

Or do anything else with it! But remember that it will always return the array untouched, so even if you try to update the array, this won't return the updated values to the next chained call!

Ok, ok... I will now show you how to implement this so called tap method. First of all, we need to augment the capabilities of the Array object in JavaScript to be able to chain call the tap method like that.

Array.prototype.tap = function() {
    // ...
};

Now, we need to find a way to get the array that we want to iterate over. We can do that by using the this keyword to get the full array. Let's use a for...of loop to loop over each elements of that array.

Array.prototype.tap = function() {
    for (const element of this) {
        // ...
    }
};

Now we need to do something... As you can see, in the previous examples, we passed a function as a first-class citizen. So looks like we are getting a callback as our parameter. Let's use this callback by passing it the current iterated element of our array.

Array.prototype.tap = function(callback) {
    for (const element of this) {
        callback(element);
    }
};

Last thing we want to do in order to prevent breaking the chain of calls we made earlier is to return the array untouched. Since the for...of loop won't update the array here, we can safely return the this keyword that will reference the original array here.

Array.prototype.tap = function(callback) {
    for (const element of this) {
        callback(element);
    }

    return this;
};

But nothing tells us that the people behind the ECMAScript standard won't implement a tap method as part of the Array prototype. Maybe they will read this article and think about how useful this function is! And if you keep your script as is, and use a newer (hypothetic) version of JavaScript that implement such a functionality, you may end up breaking your script as this definition will clash with the standard definition. We need to add a special guard to prevent such cases from happening.

if (!Array.prototype.tap) {
    Array.prototype.tap = function(callback) {
        for (const element of this) {
            callback(element);
        }

        return this;
    };
}

Ah! That's better. We could also make the for...of loop a one liner using the forEach method of Arrays instead. Since this is an array, it can easily be used for this purpose, just for the sake of saving some bytes.

if (!Array.prototype.tap) {
    Array.prototype.tap = function(callback) {
        this.forEach(element => callback(element));

        return this;
    };
}

And here is the final source-code.

"use strict";

if (!Array.prototype.tap) {
    Array.prototype.tap = function(callback) {
        this.forEach(element => callback(element));

        return this;
    };
}

const input = "732829320";

const valid =
    Array
        .from(input)
        .tap(console.log)
        .map(character => parseInt(character) || 0)
        .map((digit, index) => index % 2 === 0 ? digit : digit * 2)
        .map(digit => digit > 9 ? digit - 9 : digit)
        .reduce((sum, digit) => sum + digit) % 10 === 0;

console.log(valid);

Now you can easily track down your state and bugs by using this neat little trick!

You could also use a map to mimic this kind of behavior, without having to write a definition for the tap method.

const valid =
    Array
        .from(input)
        .map(character => { console.log(character); return character; })
        .map(character => parseInt(character) || 0)
        .map((digit, index) => index % 2 === 0 ? digit : digit * 2)
        .map(digit => digit > 9 ? digit - 9 : digit)
        .reduce((sum, digit) => sum + digit) % 10 === 0;

And it would totally work! It has the advantage of not taking the risk of clashing with an hypothetical ECMAScript definition of a tap method (though we added a guard for such case) and with the drawback of being a little bit of a mouthful.

Should you use it? Some are saying that using prototype-based inheritance in some cases can lead to problematic behavior that can be hard to track in case of bugs. But I think we can agree that well used, these kinds of patterns can be powerful and really enjoyable to use for the Developer Experience. There is an interesting conversation in the comment section that continues on that idea so I suggest you don't stop just here and keep going!

Discussion (6)

Collapse
blindfish3 profile image
Ben Calder

Interesting... but you need a warning that this is an anti-pattern:

Array.prototype.tap = function() {
    // ...
};

You should almost always avoid adding to built-in prototypes!

I also may have misunderstood the problem; but why not do everything in a single reduce?

const input = "732829320";

const valid =
    input.split('') // old-school conversion of string to array :)
        .reduce((sum, character, index) => {
          console.log(character);
          // assuming you're confident you're getting numerical values
          // in your string you can use `+`` instead of parseInt
          const digit = index % 2 === 0 ? +character : +character * 2
          return sum + (digit > 9 ? digit - 9 : digit);
        }) % 10 === 0;

console.log(valid);

If nothing else you can achieve the equivalent of your tap method with map:

.map(character => {
          console.log(character);
          return character;
        })
Collapse
aminnairi profile image
Amin Author

Hi Ben and thanks for your answer.

Interesting... but you need a warning that this is an anti-pattern:
You should almost always avoid adding to built-in prototypes!

Maybe I didn't quite catch all that was in your example but could you elaborate more on how is this an anti-pattern? I think it can greatly benefit to others reading your comment!

From my personal point of view, I don't think it is an anti-pattern as it is a feature of the language that, well used, can be really useful, powerful and cool to use! And by well used, I mean no overriding of existing prototypes, just augmenting the capabilities of the language as I said in this post. I also said that maybe they (the TC39 committee's people) will add a tap method but looking at what the proposals are here, I think we are good for a while since it is not even a staged feature (and probably never will, but I can dream, right? haha).

I also may have misunderstood the problem; but why not do everything in a single reduce?

That's a really great way of reducing the complexity of the problem. That clearly makes it shorter. But maybe harder to work on.

Unfortunately, that was not the point of this article as this was only a pretext to show how to use the tap prototype we built in this post. But I'll keep in mind your idea as it may serves me someday! Thanks for pointing that out. I'll update my post accordingly to explain clearly that this is not a production code.

If nothing else you can achieve the equivalent of your tap method with map:

You are correct! That's an another way of doing the tap method as an equivalent. But isn't it a bit of a mouthful though? Adding the tap method with the console.log call just between two map calls seems to me shorter, easier to work with especially when doing only debugging. If I ever forgot to put the last return at the end, I'll end up with two bugs (the one that I am looking for and this one). Plus it forces you to write two separate instructions as the console.log call does not return the called arguments (unfortunately).

I guess what I am trying to say here is that JavaScript is a wonderful piece of programming language and that it supports many programming paradigms. It's up to the reader to choose how he want to use this language. I'm just trying to show other opportunities of doing things but it absolutely does not mean that it's the best way of doing it. Maybe it won't suit you and that's fine to me.

Again, thank you for your comment. I'm taking notes of what you said to improve my article!

Collapse
blindfish3 profile image
Ben Calder • Edited

The reason for not extending built-in objects is well documented and founded on historical experience. It's a similar reason for avoiding global variables: you risk naming clashes and unexpectedly breaking code elsewhere in your application.

I'm not saying the approach is entirely without merit; but I think it's sensible to include a warning about this up-front so people understand the risks ;)

You do (later) add a guard; but you only talk about 'tap' being added to the language. But the problems come when you're working with multiple libraries; each of which extends the same native object with a same named function; each with a different implementation... Welcome to bug hell :(
Extending native prototypes happened a lot in the early days of JS; and people soon realised it was a really bad idea.

Understood on this being example code; and agreed that the solution does boil down to coding style ;)
The suggestion to use map() with a console log was included to show that there was a simple enough alternative to tap that didn't come with the risk of extending the native prototype.

And my comment wasn't meant as a criticism: it's a good article; and if nothing else highlights JavaScript's often misunderstood use of prototype-based inheritance.

Thread Thread
aminnairi profile image
Amin Author

Thanks for clearing that out!

I'm divided between showcasing a feature, but knowing this is a risky one that can be misused or not sharing something that could be useful for others. But in the end, I always end up sharing with the community! That's why I try to add as many guards as possible and experiment a little bit on my own. As shown in this article.

Agreed on saying that using this pattern in a library and sharing that with the OSS community can lead to several and critical bugs, that's why I personally never do that (mostly because I'm only working in a private community and rarely write entire modules for the OSS community, unfortunately!).

But this is a subject I would love to talk about for hours and would greatly deserve an entire post...!

Collapse
dorshinar profile image
Dor Shinar

Thanks for the article. I don't fully understand the difference between your Array#tap and the built-in Array#forEach.

Collapse
aminnairi profile image
Amin Author • Edited

Hey Dor, thanks for your message!

The simple difference is that theforEach method will not return anything except undefined. This is a problem! Since we want to map to a function but still continue the chain calls of maps. So we cannot do something like that in JavaScript.


[1, 2, 3].map(something).forEach(console.log).map(somethingElse);

Since it will return undefined, you will get an error trying to chain call the map method on an undefined value.

I'll add that to my post because I think it can benefit to others. Thanks again for your question!

Forem Open with the Forem app