Functional Tap
Tags: functional, javascript
Published: 2020-04-05 14:38:04

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:

```javascript
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](https://dev.to/jamesrweb/functional-pipe-28o2) 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:

```javascript
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` and `pipeWith` functions from the [functional pipe article](https://dev.to/jamesrweb/functional-pipe-28o2) 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:

```javascript
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:

```javascript
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](https://jestjs.io/en/) as our test runner:

```javascript
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.

```javascript
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:

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:

```javascript
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:

```javascript
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:

```javascript
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` and `pipeWith` functions from a previous article](https://dev.to/jamesrweb/functional-pipe-28o2) 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!