Graduate Program KB

Monads

A monad is a design pattern in functional programming that allows you to chain together computations while abstracting away side effects, making code more modular, composable, and easier to reason about. Monads are typically associated with languages like Haskell, but they can also be implemented in JavaScript.

A monad consists of a type constructor and two operations: bind (also called flatMap or chain) and return (also called of or unit). The type constructor wraps a value in the monad context, and the two operations adhere to the following three monad laws:

  1. Left identity: of(a).bind(f) ≡ f(a)
  2. Right identity: m.bind(of) ≡ m
  3. Associativity: m.bind(f).bind(g) ≡ m.bind(x => f(x).bind(g))

Let's implement the Maybe monad in JavaScript as an example:

class Maybe {
  constructor(value) {
    this.value = value;
  }

  static of(value) {
    return new Maybe(value);
  }

  isNothing() {
    return this.value === null || this.value === undefined;
  }

  bind(f) {
    return this.isNothing() ? Maybe.of(null) : f(this.value);
  }

  // Optional: implement map and ap to make Maybe a functor and an applicative functor, respectively
  map(f) {
    return this.bind(val => Maybe.of(f(val)));
  }

  ap(maybeFn) {
    return maybeFn.map(this.value);
  }
}

Now let's see how to use the Maybe monad in JavaScript:

const safeDivide = (numerator, denominator) => {
  if (denominator === 0) {
    return Maybe.of(null);
  } else {
    return Maybe.of(numerator / denominator);
  }
};

const maybeResult1 = Maybe.of(4).bind(x => safeDivide(x, 2)); // Maybe(2)
const maybeResult2 = Maybe.of(4).bind(x => safeDivide(x, 0)); // Maybe(null)

In this example, the Maybe monad helps you handle potential division by zero errors in a clean, composable way. By using the bind method, you can chain together computations that might fail, while the Maybe monad takes care of propagating the failure without any explicit error handling in your code.

Monads can be thought of as a container for a value or a computation, with rules for how to combine them. They are used in functional programming to manage side effects, such as error handling, state management, or asynchronous operations, while preserving the purity and composability of functions.

The "Maybe" monad has two subclasses: "Just" and "Nothing". The Maybe monad is a powerful tool in functional programming because it allows you to handle computations that might fail, like null or undefined values, without using explicit error handling, such as try-catch blocks or if-else statements. It makes code more modular, composable, and easier to reason about.

Here's a detailed implementation of the Maybe monad in JavaScript:

class Maybe {
  constructor(value) {
    this.value = value;
  }

  static of(value) {
    return value === null || value === undefined ? new Nothing() : new Just(value);
  }

  static just(value) {
    return new Just(value);
  }

  static nothing() {
    return new Nothing();
  }

  isNothing() {
    return this instanceof Nothing;
  }

  isJust() {
    return this instanceof Just;
  }
}

class Just extends Maybe {
  bind(f) {
    return f(this.value);
  }
}

class Nothing extends Maybe {
  bind(_) {
    return this; // A Nothing value does not call the function and remains a Nothing
  }
}

Now let's create a simple example to demonstrate the power of the Maybe monad. Imagine we have a dictionary of users and we need to fetch user data, which might not always be available.

const users = {
  1: { name: 'Alice', age: 30 },
  2: { name: 'Bob', age: 25 },
  // User 3 is missing
  4: { name: 'David', age: 40 },
};

const getUser = id => Maybe.of(users[id]);

const getUserName = user => Maybe.of(user.name);

The getUser and getUserName functions use the Maybe monad to handle missing users and user properties. They return a Just with the desired value when it exists or a Nothing when it doesn't.

Now let's retrieve user names using the Maybe monad:

const maybeName1 = getUser(1).bind(getUserName); // Just('Alice')
const maybeName2 = getUser(3).bind(getUserName); // Nothing()

In this example, the Maybe monad allows you to handle missing data in a clean and composable way. By using the bind method, you can chain together computations that might fail (e.g., when a user or a user property is missing). The Maybe monad takes care of propagating the failure (Nothing) without any explicit error handling in your code.

The Maybe monad's power lies in its ability to abstract away the need for explicit null checks and error handling. It simplifies code by automatically propagating Nothing values through a chain of computations, making it easier to focus on the main logic rather than handling edge cases.

As mentioned before, a monad consists of:

  1. A type constructor: It wraps a value inside the monad context.
  2. Two operations: a. bind (or flatMap, chain): It takes a function that accepts a value and returns a new monad, then applies this function to the wrapped value inside the monad and returns the result. b. return (or of, unit): It lifts a value into the monad context.

Let's implement the Either monad in JavaScript, which is useful for error handling. The Either monad has two subclasses: Left representing an error, and Right representing a successful value.

class Either {
  constructor(value) {
    this.value = value;
  }

  static of(value) {
    return new Right(value);
  }
}

class Left extends Either {
  bind(_) {
    return this; // A Left value does not call the function and remains a Left
  }
}

class Right extends Either {
  bind(f) {
    try {
      return f(this.value); // A Right value calls the function and changes accordingly
    } catch (e) {
      return new Left(e); // If an error occurs, return a Left with the error
    }
  }
}

Now let's use the Either monad with a real-life example. We'll create a simple user registration flow that checks if a user's age is valid and generates a username.

const checkAge = age => {
  if (age >= 18) {
    return new Right(age);
  } else {
    return new Left(new Error('Age must be at least 18.'));
  }
};

const generateUsername = age => {
  if (typeof age === 'number') {
    return new Right(`user${age}`);
  } else {
    throw new Error('Invalid age provided');
  }
};

const registerUser = age =>
  checkAge(age)
    .bind(generateUsername)
    .bind(username => new Right(`Registration successful. Your username is ${username}.`));

Now let's test the registration flow:

console.log(registerUser(25)); // Right('Registration successful. Your username is user25.')
console.log(registerUser(15)); // Left('Age must be at least 18.')
console.log(registerUser('20')); // Left('Invalid age provided.')

In this example, the Either monad allows us to chain checkAge and generateUsername in a clean and composable manner. If any of the steps fail, the error is propagated through the chain without any explicit error handling, as the Left value simply bypasses the remaining computations.

Let's dive into another example of monads in JavaScript, this time using the IO monad. The IO monad is useful for encapsulating and managing side effects, such as reading from or writing to the file system, console, or other input/output operations.

In the IO monad:

  1. The type constructor wraps a function, representing a computation that can produce a side effect.
  2. The bind operation composes two IO computations, chaining them together.
  3. The of operation lifts a value into the IO context, creating a computation that does not produce side effects.

Here's the implementation of the IO monad in JavaScript:

class IO {
  constructor(effectFn) {
    if (typeof effectFn !== 'function') {
      throw new Error('IO requires a function as its argument.');
    }

    this.effectFn = effectFn;
  }

  static of(value) {
    return new IO(() => value);
  }

  bind(f) {
    return new IO(() => {
      const value = this.effectFn();
      const nextIO = f(value);
      return nextIO.effectFn();
    });
  }
}

Now let's use the IO monad to create a simple program that reads a user's name from the console, processes the name, and then writes a personalized greeting back to the console.

First, let's create some helper functions that will be wrapped by the IO monad:

const readline = require('readline').createInterface({
  input: process.stdin,
  output: process.stdout,
});

const readName = () =>
  new IO(() =>
    readline.question('What is your name? ', name => {
      readline.close();
      return name;
    }),
  );

const processName = name => name.trim().toUpperCase();

const writeGreeting = name =>
  new IO(() => {
    console.log(`Hello, ${name}!`);
  });

Now we can chain these computations together using the IO monad:

const greetUser = readName()
  .bind(name => IO.of(processName(name)))
  .bind(writeGreeting);

The greetUser value is an IO monad that represents the entire computation, including reading the user's name, processing it, and writing a greeting. However, the computation has not been executed yet. To run the computation and produce the side effects, we need to call the effectFn function stored within the IO monad:

greetUser.effectFn();

In this example, the IO monad allows you to chain together input/output computations in a clean and composable manner, while keeping the side effects isolated until you explicitly decide to execute them. This makes it easier to reason about your code and manage side effects in functional programming.

The verbose explanation here is that the IO monad allows you to create a sequence of operations involving side effects, such as reading from or writing to the console, without actually causing those side effects until you explicitly execute the computation. This makes it easier to reason about the code, as it separates the definition of the computation from its execution. The bind operation composes two IO computations together, allowing you to create a chain of operations that depend on the results of previous operations. The of operation lifts a value into the IO context, creating a computation that does not produce side effects.