DEV Community

Cover image for Forever Functional: Higher-order functions with TypeScript
OpenReplay Tech Blog
OpenReplay Tech Blog

Posted on • Updated on • Originally published at blog.openreplay.com

Forever Functional: Higher-order functions with TypeScript

by Federico Kereki

In a previous article in this series, I discussed several types of higher-order functions (HOF) whose arguments and results may be functions themselves. All the examples there, were done in vanilla JavaScript, but nowadays it's more common to work with TypeScript, so we need to add appropriate data typing to our code. However, this is more than just trivial!

In this article, I'll review some examples from my previous article, focusing instead on the necessary data types and additional requirements that we'll also have to consider. And, as a bonus, we'll consider an extra method with several typing quirks of its own!

Wrapping functions

This type of HOF takes a function as an argument and returns a new one with the same functionality but some added features like logging or timing. Let's work with the former because solutions for both cases are practically identical.

Suppose we have a function and want to automatically add some logging, so we'll see what arguments it got and what value it returned. In plain JavaScript, we'd write something like this.

function addLogging(fn) {
  return (...args) => {
    console.log(`entering ${fn.name}(${args})`);
    try {
      const valueToReturn = fn(...args);
      console.log(`exiting  ${fn.name}=>${valueToReturn}`);
      return valueToReturn;
    } catch (thrownError) {
      console.log(`exiting  ${fn.name}=>threw ${thrownError}`);
      throw thrownError;
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Upon entering, we log whatever arguments we get. Then, we try to call the original function, store the returned value, log it, and return it. If the function throws an exception, we log that and throw the error up for processing. How do we write this in TypeScript?

function addLogging<T extends (...args: any[]) => any>(
  fn: T
): (...args: Parameters<T>) => ReturnType<T> {
  return (...args: Parameters<T>): ReturnType<T> => {
    .
    . everything as above
    .
  };
}
Enter fullscreen mode Exit fullscreen mode

Our addLogging() HOF will need a generic type definition because it can deal with all kinds of functions with different data types involved. The first line defines a generic type, T, that represents a function with an unknown number of arguments, of any type, and returns a value, also of any type. In the second line, we specify that the input function fn is of type T. So far, so good!

How do we specify the format of the returned function? The key here is that the output type must fully match the input function. We cannot just say that the returned function will have any arguments; we have to say that they match the types of the parameters of T, which is written as Parameters<T> using a utility type in TypeScript.

Similarly, we cannot just say that the output function's result is of any type. We want to say it will be the same type as the result of our function of type T. For this, TypeScript provides another utility type, ReturnType<T>.

Now we can understand more clearly the first four lines: we defined a generic type of function T, we said that the input function was of that type, and we said that our HOF would return a new function whose parameters and result would have the same types as those of T; neat!

Let's now consider a different kind of problem, where the output function need not match the typing for the input one.

Altering functions

These HOFs take a function as input and produce a new function with changed functionality. In the previous article, we considered examples such as negating conditions (to simplify filtering operations) or changing the number of parameters (arity) of functions. Let's review the first one for our analysis, which will provide a reasonably simple example of the needed data typing.

Suppose we have a function that tests for a condition; with it we can write code as someData.filter(testCondition), and the resulting array will only include the array elements that satisfy the test. But what would we do if we wanted the elements that did not satisfy the condition? If we had a not() HOF to negate a predicate (that is, to produce the opposite result), we could just write someData.filter(not(testCondition)), and it would be clear enough.

A simple implementation of not() could be the following -- and let's go with an arrow function for variety, so we'll see how TypeScript works with those.

const not = (fn) => (...args) => !fn(...args);
Enter fullscreen mode Exit fullscreen mode

Given a function, we create a new function that returns the opposite of whatever the original function would have returned. How do we write data types for this?

const not =
  <T extends (...args: any[]) => boolean>(fn: T) =>
  (...args: Parameters<T>): boolean =>
    !fn(...args);
Enter fullscreen mode Exit fullscreen mode

We'll write data types similar to what we had in the previous section. The second line shows how a generic function is specified; the generic type T goes before the definition of the function, after the assignment operator. In this case, since we want to apply not() to boolean functions, we will be clear and say that's the expected type of the input fn function, also in the second line.

What will be returning? We'll produce a new function whose parameters must match those of fn (in the same way as with logging()), and the result type will be boolean. If you prefer, write ReturnType<T> instead; whatever you think is clearer.

The experience gained with the logging() HOF was helpful with not(); let's see the third example, with an extra complication.

Open Source Session Replay

OpenReplay is an open-source, session replay suite that lets you see what users do on your web app, helping you troubleshoot issues faster. OpenReplay is self-hosted for full control over your data.

OpenReplay

Start enjoying your debugging experience - start using OpenReplay for free.

Creating new functions

The last type of HOFs that we considered in the previous article were able to produce new functions of their own. We discussed converting a method into a function (demethodizing) or transforming a callback-based async function into a promise. Let's see how we would demethodize functions, which provides an excellent example of the problems we need to solve.

In the previous article, we showed how to "demethodize" any method and transform it into a function. We saw a way to do this with bind():

const demethodize = (fn) => (...args) => fn.bind(...args)();
Enter fullscreen mode Exit fullscreen mode

How do we convert this to TypeScript? We could think of using the same kind of solution by defining a type T and writing:

const demethodize =
  <T extends (...args: any[]) => any>(fn: T) =>
  (...args: Parameters<T>): ReturnType<T> =>
    fn.bind(...args)();
Enter fullscreen mode Exit fullscreen mode

But this won't work! The last line will be marked as an error: "A spread argument must either have a tuple type or be passed to a rest parameter. ts(2556)" What's going on? The issue is that we cannot use the spread syntax (which allows for any number of arguments) with functions that expect a fixed number of parameters. In this case, bind() requires at least one first argument: the object that will assume the value of this.

How do we specify that the variable number of arguments includes at least one? The simplest way is to separate the initial argument as follows:

const demethodize =
  <T extends (arg0: any, ...args: any[]) => any>(fn: T) =>
  (arg0: any, ...args: Parameters<T>): ReturnType<T> =>
    fn.bind(arg0, ...args)();
Enter fullscreen mode Exit fullscreen mode

We are telling TypeScript that the list of arguments to our "demethodized" method will always include at least an initial arg0; problem solved!

Creating new methods

Let's have an extra HOF for additional challenges! Instead of "demethodizing" a method to convert it into a function, what about "methodizing" a function to add it to some object's prototype?

For instance, we could want to add a reverse() method to strings, so we could write something like "URUGUAY".reverse() and get "YAUGURU". (Arrays already have a reverse() method, but strings do not.) First, we should write the needed function.

function reverse(x: string): string {
  return x.split("").reverse().join("");
}
Enter fullscreen mode Exit fullscreen mode

The first parameter for any function to be "methodized" must always be whatever object the method will work on. Working in JavaScript, to add a new method to a prototype, we would require a methodize() function like this:

function methodize(obj, fn) {
  obj.prototype[fn.name] = function (...args) {
    return fn(this, ...args);
  };
}
Enter fullscreen mode Exit fullscreen mode

With it, we could write methodize(String, reverse), and from now on all strings will have gained a brand new reverse() method; good! But how do we write this in TypeScript? The answer is not too easy!

function methodize<
  T extends any[],
  F extends (arg0: any, ...args: T) => any,
  O extends { prototype: { [key: string]: any } }
>(obj: O, fn: F) {
  obj.prototype[fn.name] = function (
    this: Parameters<F>[0],
    ...args: T
  ): ReturnType<F> {
    return fn(this, ...args);
  };
}
Enter fullscreen mode Exit fullscreen mode

Wow! The first two generic types look familiar. T represents a varying number of parameters, and F is a function with a mandatory first arg0 parameter (as we saw in the previous section). The seventh line, the one starting with this:, is interesting too. You cannot have an argument or variable called this, and here we're saying that the value of this will be the same type as arg0. The actual function won't have an extra argument; this is just a trick used by TypeScript.

But what about type O? The issue is that TypeScript objects to our assignment to obj.prototype, because it cannot tell if obj actually has a prototype attribute. Type O says that the object to which we'll attach a new method does actually have a prototype, so everything is OK.

Are we done? Unhappily, no. If we write the following "URUGUAY" .reverse() as we wanted, we'll get an error: "Property' reverse' does not exist on type '"MONTEVIDEO"'. ts(2339)". The problem is that TypeScript works with static typing and cannot tell that the reverse() will eventually exist. So, we need to add a global declaration like this:

declare global {
  interface String {
    reverse(): string;
  }
}
Enter fullscreen mode Exit fullscreen mode

Everything will finally be OK with this definition (that could also appear in a .d.ts file); we added a new method to all strings and let TypeScript know about it -- great!

Conclusion

Working with higher-order functions in TypeScript and writing correct data typing for them can sometimes be a frustrating puzzle, requiring several attempts, back and forth, until the right solution is achieved. I hope this article will help you on your way to modern, typed Functional Programming!

A TIP FROM THE EDITOR: It will help if you read the original Higher Order Functions -- Functions To Rule Functions article, in which all these functions are explained.

newsletter

Top comments (0)