Skip to content

Functors and Monads

A functor is a container that can be mapped over, preserving structure while transforming values.

  1. Identity: functor.map(x => x) equals functor
  2. Composition: functor.map(f).map(g) equals functor.map(x => g(f(x)))
// Arrays are functors
const numbers = [1, 2, 3];
// Map preserves structure (still an array)
const doubled = numbers.map(x => x * 2);
// [2, 4, 6]
// Identity law
const identity = numbers.map(x => x);
// [1, 2, 3] - same as original
// Composition law
const addOne = (x: number) => x + 1;
const double = (x: number) => x * 2;
const composed1 = numbers.map(addOne).map(double);
const composed2 = numbers.map(x => double(addOne(x)));
// Both produce [4, 6, 8]

Handle nullable values safely without null checks.

type Maybe<T> = Some<T> | None;
class Some<T> {
constructor(private value: T) {}
map<U>(fn: (value: T) => U): Maybe<U> {
return new Some(fn(this.value));
}
flatMap<U>(fn: (value: T) => Maybe<U>): Maybe<U> {
return fn(this.value);
}
getOrElse(defaultValue: T): T {
return this.value;
}
isNone(): boolean {
return false;
}
}
class None implements Maybe<never> {
map<U>(_fn: (value: never) => U): Maybe<U> {
return new None();
}
flatMap<U>(_fn: (value: never) => Maybe<U>): Maybe<U> {
return new None();
}
getOrElse<T>(defaultValue: T): T {
return defaultValue;
}
isNone(): boolean {
return true;
}
}
// Helper function
function maybe<T>(value: T | null | undefined): Maybe<T> {
return value != null ? new Some(value) : new None();
}
// Without Maybe - lots of null checks
function getUserEmail(userId: number): string | null {
const user = findUser(userId);
if (!user) return null;
const profile = user.profile;
if (!profile) return null;
const email = profile.email;
if (!email) return null;
return email.toLowerCase();
}
// With Maybe - clean composition
function getUserEmailSafe(userId: number): Maybe<string> {
return maybe(findUser(userId))
.flatMap(user => maybe(user.profile))
.flatMap(profile => maybe(profile.email))
.map(email => email.toLowerCase());
}
// Usage
const email = getUserEmailSafe(123).getOrElse('no-email@example.com');
// Chain multiple operations safely
const result = maybe(5)
.map(x => x * 2) // Some(10)
.map(x => x + 1) // Some(11)
.map(x => x.toString()) // Some('11')
.getOrElse('default'); // '11'
// None propagates through chain
const nullResult = maybe(null)
.map(x => x * 2)
.map(x => x + 1)
.getOrElse(0); // 0

Handle errors without exceptions, preserving error information.

type Either<E, T> = Left<E> | Right<T>;
class Left<E> {
constructor(private error: E) {}
map<U>(_fn: (value: never) => U): Either<E, U> {
return new Left(this.error);
}
flatMap<U>(_fn: (value: never) => Either<E, U>): Either<E, U> {
return new Left(this.error);
}
getOrElse<T>(defaultValue: T): T {
return defaultValue;
}
isLeft(): boolean {
return true;
}
fold<U>(onLeft: (error: E) => U, _onRight: (value: never) => U): U {
return onLeft(this.error);
}
}
class Right<T> {
constructor(private value: T) {}
map<U>(fn: (value: T) => U): Either<never, U> {
return new Right(fn(this.value));
}
flatMap<E, U>(fn: (value: T) => Either<E, U>): Either<E, U> {
return fn(this.value);
}
getOrElse(_defaultValue: T): T {
return this.value;
}
isLeft(): boolean {
return false;
}
fold<U>(_onLeft: (error: never) => U, onRight: (value: T) => U): U {
return onRight(this.value);
}
}
function divide(a: number, b: number): Either<string, number> {
return b === 0
? new Left('Division by zero')
: new Right(a / b);
}
function safeSqrt(n: number): Either<string, number> {
return n < 0
? new Left('Cannot take square root of negative number')
: new Right(Math.sqrt(n));
}
// Chain operations that can fail
const result = divide(10, 2)
.flatMap(x => safeSqrt(x))
.map(x => x * 2);
result.fold(
error => console.error('Error:', error),
value => console.log('Result:', value)
);
// Result: ~4.47
// Error case
const errorResult = divide(10, 0)
.flatMap(x => safeSqrt(x))
.map(x => x * 2);
errorResult.fold(
error => console.error('Error:', error),
value => console.log('Result:', value)
);
// Error: Division by zero
interface User {
email: string;
age: number;
name: string;
}
function validateEmail(email: string): Either<string, string> {
return email.includes('@')
? new Right(email)
: new Left('Invalid email format');
}
function validateAge(age: number): Either<string, number> {
return age >= 18 && age <= 120
? new Right(age)
: new Left('Age must be between 18 and 120');
}
function validateName(name: string): Either<string, string> {
return name.length >= 2
? new Right(name)
: new Left('Name must be at least 2 characters');
}
function validateUser(user: User): Either<string, User> {
return validateEmail(user.email)
.flatMap(() => validateAge(user.age))
.flatMap(() => validateName(user.name))
.map(() => user);
}
// Usage
const validUser = validateUser({
email: 'user@example.com',
age: 25,
name: 'John'
});
validUser.fold(
error => console.error('Validation failed:', error),
user => console.log('Valid user:', user)
);

Lazy evaluation of side effects.

class Task<T> {
constructor(private computation: () => Promise<T>) {}
map<U>(fn: (value: T) => U): Task<U> {
return new Task(async () => {
const value = await this.computation();
return fn(value);
});
}
flatMap<U>(fn: (value: T) => Task<U>): Task<U> {
return new Task(async () => {
const value = await this.computation();
const task = fn(value);
return task.run();
});
}
run(): Promise<T> {
return this.computation();
}
}
// Helper
function task<T>(computation: () => Promise<T>): Task<T> {
return new Task(computation);
}
// Define tasks (not executed yet)
const fetchUser = (id: number) =>
task(() => fetch(`/api/users/${id}`).then(r => r.json()));
const fetchPosts = (userId: number) =>
task(() => fetch(`/api/users/${userId}/posts`).then(r => r.json()));
// Compose tasks
const getUserWithPosts = (id: number) =>
fetchUser(id).flatMap(user =>
fetchPosts(user.id).map(posts => ({ ...user, posts }))
);
// Execute only when needed
const result = await getUserWithPosts(1).run();

All monads must satisfy three laws:

// Wrapping a value and flatMapping should be the same as just applying the function
const value = 5;
const f = (x: number) => new Right(x * 2);
// These should be equivalent:
new Right(value).flatMap(f); // Right(10)
f(value); // Right(10)
// flatMapping with the constructor should return the same monad
const monad = new Right(5);
monad.flatMap(x => new Right(x)); // Right(5)
monad; // Right(5)
// The order of flatMap operations shouldn't matter
const monad = new Right(5);
const f = (x: number) => new Right(x + 1);
const g = (x: number) => new Right(x * 2);
// These should be equivalent:
monad.flatMap(f).flatMap(g);
monad.flatMap(x => f(x).flatMap(g));
function sequence<E, T>(eithers: Either<E, T>[]): Either<E, T[]> {
const results: T[] = [];
for (const either of eithers) {
if (either.isLeft()) {
return either as Either<E, T[]>;
}
results.push(either.fold(() => null as never, x => x));
}
return new Right(results);
}
// Usage
const results = [
divide(10, 2),
divide(20, 4),
divide(30, 5)
];
sequence(results).fold(
error => console.error('Error:', error),
values => console.log('All results:', values)
);
function traverse<T, U>(
array: T[],
fn: (value: T) => Maybe<U>
): Maybe<U[]> {
const results: U[] = [];
for (const item of array) {
const result = fn(item);
if (result.isNone()) {
return new None();
}
results.push(result.getOrElse(null as never));
}
return new Some(results);
}
// Usage
const numbers = ['1', '2', '3'];
const parsed = traverse(numbers, str => {
const num = parseInt(str, 10);
return isNaN(num) ? new None() : new Some(num);
});
parsed.getOrElse([]); // [1, 2, 3]

✅ Use Maybe for nullable values ✅ Use Either for error handling ✅ Chain operations with flatMap ✅ Keep monadic operations pure ✅ Use fold to extract values ✅ Leverage type safety ✅ Compose monadic functions ✅ Handle errors explicitly

❌ Use exceptions with Either ❌ Mix null checks with Maybe ❌ Forget to handle error cases ❌ Create deep nesting with flatMap ❌ Ignore monad laws ❌ Use monads for simple values ❌ Over-complicate simple code