Learning Objectives

  • Understand what monads are and why they matter
  • Learn the three monad laws
  • Implement Maybe, Either, and IO monads
  • Apply monads to real-world problems

What is a Monad?

A monad is a design pattern that wraps a value and provides a way to chain operations on that value. Think of it as a box that:

Monads help you handle edge cases (null, errors, side effects) in a consistent, composable way.

The Monad Laws

For something to be a monad, it must follow three laws:

1. Left Identity

// If you wrap a value and immediately chain a function,
// it should be the same as just calling the function
Monad.of(value).chain(f) === f(value)

2. Right Identity

// Chaining with Monad.of should do nothing
monad.chain(Monad.of) === monad

3. Associativity

// The order of chaining doesn't matter
monad.chain(f).chain(g) === monad.chain(x => f(x).chain(g))

The Maybe Monad: Handling Null Safety

The Maybe monad handles values that might be null or undefined. It has two variants: Just (has a value) and Nothing (no value).

Implementation

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

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

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

    map(fn) {
        return this.isNothing() ? this : Maybe.of(fn(this.value));
    }

    flatMap(fn) {
        return this.isNothing() ? this : fn(this.value);
    }

    getOrElse(defaultValue) {
        return this.isNothing() ? defaultValue : this.value;
    }
}

Usage Example

// Without Maybe - lots of null checks
function getUserDiscount(userId) {
    const user = findUser(userId);
    if (!user) return 0;
    
    const membership = user.membership;
    if (!membership) return 0;
    
    const discount = membership.discount;
    if (!discount) return 0;
    
    return discount;
}

// With Maybe - clean and safe
function getUserDiscount(userId) {
    return Maybe.of(findUser(userId))
        .map(user => user.membership)
        .map(membership => membership.discount)
        .getOrElse(0);
}
How it works: If any step returns null/undefined, map stops executing and returns Nothing. No null checks needed!

Real-World Example: Safe Property Access

const data = {
    user: {
        profile: {
            address: {
                city: 'San Francisco'
            }
        }
    }
};

// Unsafe - throws if any property is missing
const city = data.user.profile.address.city;

// Safe with Maybe
const safeCity = Maybe.of(data)
    .map(d => d.user)
    .map(u => u.profile)
    .map(p => p.address)
    .map(a => a.city)
    .getOrElse('Unknown');

console.log(safeCity); // "San Francisco"

// If data is incomplete
const incompleteData = { user: {} };
const result = Maybe.of(incompleteData)
    .map(d => d.user)
    .map(u => u.profile)
    .map(p => p.address)
    .map(a => a.city)
    .getOrElse('Unknown');

console.log(result); // "Unknown"

The Either Monad: Handling Errors

The Either monad represents a value that can be one of two types: Left (error) or Right (success). By convention, Right is the "right" (correct) path.

Implementation

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

    static left(value) {
        return new Either(value, true);
    }

    static right(value) {
        return new Either(value, false);
    }

    map(fn) {
        return this.isLeft ? this : Either.right(fn(this.value));
    }

    flatMap(fn) {
        return this.isLeft ? this : fn(this.value);
    }

    getOrElse(defaultValue) {
        return this.isLeft ? defaultValue : this.value;
    }

    fold(leftFn, rightFn) {
        return this.isLeft ? leftFn(this.value) : rightFn(this.value);
    }
}

Usage Example

// Without Either - try-catch everywhere
function processUser(userId) {
    try {
        const user = findUser(userId);
        if (!user) throw new Error('User not found');
        
        const validated = validateUser(user);
        if (!validated) throw new Error('Invalid user');
        
        return saveUser(validated);
    } catch (error) {
        console.error(error);
        return null;
    }
}

// With Either - functional error handling
function findUserEither(userId) {
    const user = findUser(userId);
    return user 
        ? Either.right(user) 
        : Either.left('User not found');
}

function validateUserEither(user) {
    return validateUser(user)
        ? Either.right(user)
        : Either.left('Invalid user');
}

function saveUserEither(user) {
    try {
        const saved = saveUser(user);
        return Either.right(saved);
    } catch (error) {
        return Either.left(error.message);
    }
}

function processUser(userId) {
    return findUserEither(userId)
        .flatMap(validateUserEither)
        .flatMap(saveUserEither)
        .fold(
            error => console.error('Error:', error),
            user => console.log('Success:', user)
        );
}
How it works: If any step returns Left (error), the chain stops and the error propagates. Only Right values continue through the chain.

Real-World Example: Form Validation

function validateEmail(email) {
    const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return regex.test(email)
        ? Either.right(email)
        : Either.left('Invalid email format');
}

function validateAge(age) {
    return age >= 18
        ? Either.right(age)
        : Either.left('Must be 18 or older');
}

function createUser(email, age) {
    return validateEmail(email)
        .flatMap(() => validateAge(age))
        .map(() => ({ email, age, createdAt: new Date() }))
        .fold(
            error => ({ success: false, error }),
            user => ({ success: true, user })
        );
}

console.log(createUser('test@example.com', 25));
// { success: true, user: { email: '...', age: 25, createdAt: ... } }

console.log(createUser('invalid-email', 25));
// { success: false, error: 'Invalid email format' }

console.log(createUser('test@example.com', 16));
// { success: false, error: 'Must be 18 or older' }

The IO Monad: Handling Side Effects

The IO monad wraps side effects (like reading files, making API calls, or logging) to keep your functions pure. The side effect doesn't execute until you explicitly run it.

Implementation

class IO {
    constructor(effect) {
        this.effect = effect;
    }

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

    map(fn) {
        return new IO(() => fn(this.effect()));
    }

    flatMap(fn) {
        return new IO(() => fn(this.effect()).effect());
    }

    run() {
        return this.effect();
    }
}

Usage Example

// Impure function - side effect happens immediately
function getUserName() {
    return prompt('Enter your name:'); // Side effect!
}

function greetUser() {
    const name = getUserName();
    console.log(`Hello, ${name}!`); // Another side effect!
}

// Pure function with IO - side effects are deferred
function getUserNameIO() {
    return new IO(() => prompt('Enter your name:'));
}

function greetUserIO(name) {
    return new IO(() => console.log(`Hello, ${name}!`));
}

// Build the program (no side effects yet)
const program = getUserNameIO()
    .flatMap(name => greetUserIO(name));

// Execute when ready (side effects happen here)
program.run();
Why this matters: The IO monad lets you compose side effects without executing them. This makes your code testable and allows you to control when side effects happen.

Real-World Example: API Calls

function fetchUserIO(userId) {
    return new IO(() => fetch(`/api/users/${userId}`).then(r => r.json()));
}

function fetchPostsIO(userId) {
    return new IO(() => fetch(`/api/posts?userId=${userId}`).then(r => r.json()));
}

function displayDataIO(data) {
    return new IO(() => {
        document.getElementById('output').textContent = JSON.stringify(data);
    });
}

// Compose the program
const program = fetchUserIO(1)
    .flatMap(user => 
        fetchPostsIO(user.id)
            .map(posts => ({ user, posts }))
    )
    .flatMap(displayDataIO);

// Execute when ready (e.g., on button click)
document.getElementById('loadBtn').addEventListener('click', () => {
    program.run();
});

Chaining Monads Together

// Combining Maybe and Either
function safeDivide(a, b) {
    return Maybe.of(b)
        .map(divisor => divisor !== 0 ? Either.right(a / divisor) : Either.left('Division by zero'))
        .getOrElse(Either.left('Invalid divisor'));
}

const result1 = safeDivide(10, 2);
console.log(result1.getOrElse(0)); // 5

const result2 = safeDivide(10, 0);
console.log(result2.fold(err => `Error: ${err}`, val => val));
// "Error: Division by zero"

Practical Tips

When to use Maybe:
  • Accessing nested object properties
  • Working with optional values
  • Avoiding null/undefined checks
When to use Either:
  • Form validation
  • API error handling
  • Any operation that can fail with a reason
When to use IO:
  • File system operations
  • Network requests
  • DOM manipulation
  • Any side effect you want to defer

Common Pitfalls

Pitfall 1: Forgetting to run IO
// Wrong - IO never executes
const io = new IO(() => console.log('Hello'));

// Right - call run()
io.run();
Pitfall 2: Using map instead of flatMap
// Wrong - creates nested monads
Maybe.of(5).map(x => Maybe.of(x * 2)); // Maybe(Maybe(10))

// Right - use flatMap to flatten
Maybe.of(5).flatMap(x => Maybe.of(x * 2)); // Maybe(10)
Pitfall 3: Overusing monads

Not everything needs to be a monad. Use them when they solve a real problem (null safety, error handling, side effects), not just for the sake of being "functional."

Key Takeaways