DEV Community

Wichai Sawangpongkasame
Wichai Sawangpongkasame

Posted on

A Brief Introduction to Functor for Busy TypeScript Devs

In this article, we won't be diving deep into functional programming. Instead, we'll get familiar with functors, a concept you might already be using without realizing it, and how to apply this technique to your general coding practice.

Before explaining what a Functor is, let's look at an example to see it in action and understand the benefits.

Assuming that you have an array of numbers and you want to map over it to calculate something for every member. You might write it like this:

const arr = [1, 2, 3];
arr.map((num) => num + 1); // [2, 3, 4]

Enter fullscreen mode Exit fullscreen mode

But what if you wanted to perform two different calculations?

const arr = [1, 2, 3];
arr.map((num) => num + 1).map((num) => num ** 2); // [4, 9, 16]
Enter fullscreen mode Exit fullscreen mode

If we refactor this by extracting the calculation logic into separate callback functions, we get:

const arr = [1, 2, 3];
const add1 = (num: number) => num + 1;
const square = (num: number) => num ** 2;
arr.map(add1).map(square); // [4, 9, 16]
Enter fullscreen mode Exit fullscreen mode

It should be easy to read. By using .map, we apply add1 to each element, then apply square to each element respectively.

This is a functor in action! It allows us to read the operations performed on the data sequentially and makes it simple to chain operations together. This is a common pattern in functional programming.

However, if we were being honest with ourselves, when we're accustomed to an imperative way of thinking and coding, we often don't write code as straightforwardly as the chained .map calls. We might end up with something more like this instead:

arr.map(x => {
    const addResult = add1(x);
    const squareResult = square(addResult);
    return squareResult;
})

// Or simplified:
arr.map(num => square(add1(num))); // [4, 9, 16]
Enter fullscreen mode Exit fullscreen mode

Not too hard to read yet, right? But what if we want to add one more operation?

const times2 = (num: number) => num * 2;

// 1st approach (Functor chaining)
arr.map(add1).map(square).map(times2); // [8, 18, 32]

// 2nd approach (Imperative with intermediate variables)
arr.map(x => {
    const addResult = add1(x);
    const squareResult = square(addResult);
    const times2Result = times2(squareResult);
    return times2Result;
})

// 2nd approach (Nested functions)
arr.map(num => times2(square(add1(num)))); // [8, 18, 32]
Enter fullscreen mode Exit fullscreen mode

You can see that using a functor keeps the code easy to read. We can simply read it from left to right! It takes the value from the array, add1 to it, square it, and then times by 2.

In contrast, as we start nesting functions deeper in the second approach, we have to start chasing down variables, check if we're passing the right values, or mentally keep track that the outermost function is called with the result of the innermost one, all while counting parentheses.

In terms of performance, the two approaches are negligibly different. The chained .map() runs three separate loops, while the nested approach runs all three operations in a single loop. However, since the Big O time complexity is still O(n) for both, the overhead is minimal compared to the significant gain in readability.

The fact that both writing styles yield the same result is the Composition Law of functors. We've just seen that the JavaScript Array is a type of functor, and we leverage its map property to structure our code for better readability.

We've seen the practical benefit of this pattern. So, what exactly is a Functor?

What Exactly is a Functor?

At its core, the concept of a functor is rooted in Category Theory, a branch of mathematics dealing with the transformation of objects from one category to another.

In programming terms, a Functor is a data type (or container) that we can map over to apply a function to its contents. This changes the value inside but keeps the structure the same, which makes it remains a functor that you can continue mapping.

A functor must obey two fundamental laws:

1. Identity Law

If you pass the identity function (a function that simply returns its input) to the map method, you must get a functor that is equivalent to the original one.

const arr = [1, 2, 3];
const identityFunction = (x: number) => x;
arr.map(identityFunction); // [1, 2, 3]
Enter fullscreen mode Exit fullscreen mode

We use the word "equivalent" because while the value is the same ([1, 2, 3]), .map() actually creates a new array instance. For any functor type, map returns a new functor instance.

2. Composition Law

As shown in the initial examples, both writing styles below are merely rearrangements of the same logic but produce identical results.

const arr = [1, 2, 3];
const add1 = (num: number) => num + 1;
const square = (num: number) => num ** 2;
const times2 = (num: number) => num * 2;

// Nested approach
arr.map(num => times2(square(add1(num)))); // [8, 18, 32]

// Chained approach
arr.map(add1).map(square).map(times2); // [8, 18, 32]
Enter fullscreen mode Exit fullscreen mode

We can abstract this into a formal rule:

F.map(x⇒f(g(x)))≡F.map(g).map(f)
Enter fullscreen mode Exit fullscreen mode

Where F is the functor (in our example, the array). Both forms of structuring the operations yield the same outcome.

Following this rule, you can see that several other things in JavaScript are functors, such as Promises, which you can chain with repeated .then() calls.

const promise = Promise.resolve(2)
  .then(add1)
  .then(square)
  .then(times2)
  .then(val => console.log(val)); // 18
Enter fullscreen mode Exit fullscreen mode

How to Create Your Own Functor

Based on this knowledge, we can define a few steps to build our own functor.

  1. Create a data type (a container).
  2. Implement a map method that takes a callback function and applies it to our data's value.
  3. Wrap the result back in a new instance of our functor, according to the definition that the structure must be preserved.

For simplicity, before moving on to the TypeScript example, let's look at the basic JavaScript function implementation first to grasp the concept:

const functor = (value) => ({
    map: (callback) => functor(callback(value)),
    get: () => value
})

Enter fullscreen mode Exit fullscreen mode

This function is a constructor that takes a value and creates a data structure with a map method. The .map receives a callback function, applies it to the internal value, and then uses the result of callback(value) to create a new functor instance.

We can define a simple TypeScript interface with generics to make our functor type-safe and flexible:

interface IFunctor<T> {
  map: <U>(callback: (val: T) => U) => IFunctor<U>;
  get(): T;
}

const functor = <T>(value: T): IFunctor<T> => ({
  map: <U>(callback: (val: T) => U): IFunctor<U> => functor(callback(value)),
  get: () => value,
});

const exclaim = (word: string) => word + '!';
const capitalize = (word: string) => word.charAt(0).toUpperCase() + word.slice(1);
const words = functor("i'm on a boat");

words.map(exclaim).map(capitalize); // {map: ƒ, get: ƒ} - The Functor itself
words.map(exclaim).map(capitalize).get(); // "I'm on a boat!"
Enter fullscreen mode Exit fullscreen mode

Method Chaining without the Functional Functor

For those who are more comfortable with Object-Oriented Programming (OOP), you might recognize that you can achieve method chaining by having a class method simply return this keyword, which is equivalent to maintaining the same interface structure.

class Functor<T> implements IFunctor<T> {
  constructor(private value: T) {}

  map<U>(callback: (val: T) => U): IFunctor<U> {
    // Note: We mutate the internal state and cast to the new type
    this.value = callback(this.value) as unknown as T; 
    return this as unknown as IFunctor<U>;
  }

  get(): T {
    return this.value;
  }
}

// Using the same exclaim and capitalize functions
const exclaim = (word: string) => word + '!';
const capitalize = (word: string) => word.charAt(0).toUpperCase() + word.slice(1);
const words = new Functor("i'm on a boat");

words.map(exclaim).map(capitalize); // Functor {value: "I'm on a boat!"}
words.map(exclaim).map(capitalize).get(); // "I'm on a boat!"
Enter fullscreen mode Exit fullscreen mode

As you can see, we get the same result whether we create the Functor using a functional approach or an OOP class. They are basically just a mean, from different paradigms, to the same end.

However, a key difference (especially when thinking about functional programming) is side effects. The class-based Functor keeps its state (value) and mutates it within the map method. If not encapsulated strictly, this state could be modified unexpectedly:

// Even with TypeScript's private keyword, state can be modified at runtime
words["value"] = "random words"; 
words.map(exclaim).map(capitalize).get(); // 'Random words!'
Enter fullscreen mode Exit fullscreen mode

While we can use # in modern JavaScript or rely on TypeScript's private keyword (which offers compile-time protection) to avert this OOP's state pitfall, the functional functor implementation avoids this concern altogether because it creates a new functor on every map call, ensuring immutability from the start.

There is more to it, but I hope this serve as a starting point for those who are more accustomed to OOP. I just want to demonstrate that a simple functional programming concept can get rid of the complexity overhead you have never thought of. I'm not a FP expert myself but reading SICP has shed some light on complexities I am too used to that I hadn't noticed before. More on that next time.

Top comments (0)