DEV Community

Cover image for Functional Tap
James Robb
James Robb

Posted on

Functional Tap

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);
}
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

Note that the example above uses the pipe and pipeWith functions from the functional pipe article I wrote previously in this series.

From this, we can see that:

  1. The tapSync function is a higher order function which takes the function to call as an argument
  2. The tapSync function returns a new function which will take the value(s) to call the input function with
  3. 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"
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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];
  }
}
Enter fullscreen mode Exit fullscreen mode

In both cases:

  1. We run a check to be sure the tapFn that is provided is actually a function and if it isn't we throw a TypeError.
  2. We return a function where we can provide the args value(s) to the tapFn.
  3. 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 a try/catch block inside the passThrough function.
  4. 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 an async function in the middle of a pipe for example and you want the return value to use at the next step, you should await the return value of the tap 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]);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Note that the examples above uses the pipe and pipeWith 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)