When debugging our code or applying side effects of some form, we usually end up with code which is a little quirky. For example, take the following function definition:
async function navigateTo(url) {
console.log(url);
return await navigate(url);
}
The primary issue here is that we only use the navigateTo
function as a glorified wrapper to log the new path. This kind of pattern is quite common albeit with more realistic use cases than just logging to the console. Even still the point stands.
A functional tap works especially well with a functional pipe or some similar construct and allows a side effect or other function to run without altering the initial input value which is perfect for use cases like this where we just want to log the url
and then navigate!
Updating the above example could be altered to use a functional tap like so:
const navigateTo = pipe(
tapSync(console.log),
async url => await navigate(url)
);
navigateTo("/test");
// or
pipeWith(
"/test",
tapSync(console.log),
async url => await navigate(url)
);
Note that the example above uses the
pipe
andpipeWith
functions from the functional pipe article I wrote previously in this series.
From this, we can see that:
- The
tapSync
function is a higher order function which takes the function to call as an argument - The
tapSync
function returns a new function which will take the value(s) to call the input function with - The
tapSync
function when re-invoked it returns the value(s) provided and not the return value of the input function
This means that the signature would look like so:
const name = "James";
const greet = name => `Hello, ${name}!`;
const greetTap = tapSync(greet)(name); // "James"
Note that for multiple values being used as input, the tap
function would instead return an array of those values however:
const left = 1;
const right = 2;
const add = (left, right) => left + right;
const sum = add(left, right);
const addTap = tapSync(add)(left, right) // => [1, 2]
You may also notice the function name is tapSync
and that implies there is an async tap
too which there is and you will see it in action in the tests and examples sections of this article too!
Tests
Since we need to test side effects with the tap function we will set up a mock function and spy on it using jest as our test runner:
import { tap, tapSync } from "./index";
describe("Tap()", () => {
it("Should throw when invalid arguments are provided", () => {
expect(() => tap("test")).toThrowError(TypeError);
expect(() => tapSync("test")).toThrowError(TypeError);
});
it("Sync: Runs as expected", () => {
const left = 1;
const right = 2;
const add = jest.fn();
add(left, right);
expect(add.mock.calls.length).toBe(1);
expect(add.mock.calls[0][0]).toBe(left);
expect(add.mock.calls[0][1]).toBe(right);
const addTap = tapSync(add)(left, right);
expect(add.mock.calls.length).toBe(2);
expect(addTap).toEqual([left, right]);
});
it("Sync: Throws correctly if the provided function does", () => {
function profanityCheck(input) {
throw new Error("Test error!");
}
const profanityTap = tapSync(profanityCheck);
try {
profanityTap("hi");
} catch (error) {
expect(error instanceof Error).toBe(true);
expect(error.message).toMatch("Test error!");
}
});
it("Async: Throws correctly if the provided function does", async () => {
function profanityCheck(input) {
throw new Error("Test error!");
}
const profanityTap = tap(profanityCheck);
try {
await profanityTap("hi");
} catch (error) {
expect(error instanceof Error).toBe(true);
expect(error.message).toMatch("Test error!");
}
});
it("Async: Should call the input function when a value is provided", () => {
const logger = jest.fn();
const loggerTap = tap(logger);
const logValue = "test log";
loggerTap(logValue);
expect(logger.mock.calls.length).toBe(1);
expect(logger.mock.calls[0][0]).toBe(logValue);
});
it("Async: Should be able to run as many times as necessary", () => {
const logger = jest.fn();
const loggerTap = tap(logger);
const logValue = "test log";
loggerTap(logValue);
expect(logger.mock.calls.length).toBe(1);
expect(logger.mock.calls[0][0]).toBe(logValue);
loggerTap(logValue + 1);
expect(logger.mock.calls.length).toBe(2);
expect(logger.mock.calls[1][0]).toBe(logValue + 1);
});
it("Async: Should work with promise returning functions as input", async () => {
const logger = jest.fn();
const loggerAsync = value => new Promise(resolve => {
setTimeout(() => {
resolve(
logger(value)
);
}, 3000);
});
const loggerTap = tap(loggerAsync);
const logValue = "test log";
await loggerTap(logValue);
expect(logger.mock.calls.length).toBe(1);
expect(logger.mock.calls[0][0]).toBe(logValue);
});
it("Async: Returns an array for multiple values", async () => {
const left = 1;
const right = 2;
const add = jest.fn();
add(left, right);
expect(add.mock.calls.length).toBe(1);
expect(add.mock.calls[0][0]).toBe(left);
expect(add.mock.calls[0][1]).toBe(right);
const addTap = await tap(add)(left, right);
expect(add.mock.calls.length).toBe(2);
expect(addTap).toEqual([left, right]);
});
it("Async: Returns the input value if only one is provided", async () => {
const name = "James";
const greet = jest.fn();
greet(name);
expect(greet.mock.calls.length).toBe(1);
expect(greet.mock.calls[0][0]).toBe(name);
const greetTap = await tap(greet)(name);
expect(greet.mock.calls.length).toBe(2);
expect(greetTap).toEqual(name);
});
});
We run checks for invalid parameters and that when a value is provided, the provided function is called with that value properly. We also make sure that we can call our constructed tap
multiple times.
Implementation
Having two kinds of tap gives us flexibility on which path we want to take based on the functions we want to apply the values to and how we want to use the taps in practice. It also cleans up things like logging to the console in a tap.
function tapSync(tapFn) {
if(typeof tapFn !== "function") {
throw new TypeError(`Parameter 1 must be of type Function. Received: "${typeof tapFn}".`);
}
return function passThrough(...args) {
tapFn(...args);
return args.length === 1 ? args.shift() : [...args];
}
}
function tap(tapFn) {
if(typeof tapFn !== "function") {
throw new TypeError(`Parameter 1 must be of type Function. Received: "${typeof tapFn}".`);
}
return async function passThrough(...args) {
await tapFn(...args);
return args.length === 1 ? args.shift() : [...args];
}
}
In both cases:
- We run a check to be sure the
tapFn
that is provided is actually a function and if it isn't we throw aTypeError
. - We return a function where we can provide the
args
value(s) to thetapFn
. - If anything goes wrong during the
tapFn
execution we allow errors to throw up the chain so that we have control of our error handling instead of using an opinionated approach like having atry/catch
block inside thepassThrough
function. - We return the
args
value(s) that were provided to be used further downstream as required. If one value is provided it is returned as is but if multiple are provided, these are returned as an array.
Note that, if you are using the
tap
with anasync
function in the middle of apipe
for example and you want the return value to use at the next step, you shouldawait
the return value of thetap
call. If however, you don't require the value anymore you can just call it like a synchronous function. See the tests section for examples of this!
Examples
Using the tapSync
function:
const loggerTap = tapSync(console.log);
const addFriendPipeline = pipe(
loggerTap, // [1, 3]
async userIds => findUsers(...userIds),
LoggerTap, // [{...}, {...}]?
async ([user1, user2]) => sendConnectRequest(user1, user2)
); // `true` / `false` 🤷♂️
const userId = 1;
const targetFriendId = 3;
userAddFriendPipeline([userId, targetFriendId]);
Using the tap
and using the return value could be:
const name = "James";
const sayHello = name => `Hello, ${name}!`;
const shout = string => string.toUpperCase();
const greetingPipeline = pipe(
tap(profanityCheck),
async checkTap => await checkTap,
sayHello,
shout
);
// or
const greetingPipeline = pipe(
async name => await tap(profanityCheck)(name),
sayHello,
shout
);
const greeting = greetingPipeline(name); // The name or an error if `profanityCheck` throws
Using the tap
without requiring the return value could be:
function addToCart(event) {
const { target: button } = event;
const { dataset: { productId: id } } = button;
pipeWith(
id,
async id => await getProductById(id),
async product => await addProductToCart(product),
tap(sendToAnalytics)
);
}
document.querySelector("button").addEventListener("click", addToCart);
Note that the examples above uses the
pipe
andpipeWith
functions from a previous article in this series
Conclusions
A tap is mostly useful in a pipe or compose function where you want a value to pass through and clean up the other functions in the execution order by putting calls such as to console.log
in the pipe or composer itself and thus reducing bloat and increasing readability.
I hope you found some value in this article and can see how an approach like this can help you use side effects in your flows and pipes without generating extra unnecessary code!
Top comments (0)