Monad I
Async/error handling, promises/optionals
__ __ ___ _ _ _ ____ ____
| \/ |/ _ \| \ | | / \ | _ \/ ___|
| |\/| | | | | \| | / _ \ | | | \___ \
| | | | |_| | |\ |/ ___ \| |_| |___) |
|_| |_|\___/|_| \_/_/ \_\____/|____/
This one is a bit tough to explain in one go. So I'll start with a real problem. Say, we need to:
- Fetch user data from a database (might fail)
- Validate the user's permissions (might be unauthorized)
- Process their request (might contain invalid data)
- Save the results (might encounter storage errors)
One way to solve it by using a straightforward approach:
const database = {
getUser: (userId) => {
// Simulate a user fetch
return { id: userId, name: "John Doe" };
}
};
const auth = {
checkPermissions: (user) => {
// Simulate permission check
return user ? { canAccess: true } : null;
}
};
const validator = {
validate: (data) => {
// Simulate data validation
return data && typeof data === 'object' ? data : null;
}
};
const processor = {
process: (data) => {
// Simulate data processing
return data ? { processed: true, data } : null;
}
};
const storage = {
save: (data) => {
// Simulate data storage
return data ? { saved: true, data } : null;
}
};
function processUserRequest(userId, requestData) {
const user = database.getUser(userId);
if (user) {
const permissions = auth.checkPermissions(user);
if (permissions) {
const validatedData = validator.validate(requestData);
if (validatedData) {
const processedData = processor.process(validatedData);
if (processedData) {
const saved = storage.save(processedData);
if(saved) {
return { success: saved };
}
else {
return { error: "Storage failed" };
}
} else {
return { error: "Processing failed" };
}
}
else {
return { error: "Invalid data" };
}
}
else{
return { error: "Permission denied" };
}
}
else{
return { error: "User not found" };
}
}
var result = processUserRequest(1, { key: "value" });
console.log(result);
There are two major difficulties with this code.
-
Synchronous invocation. It assumes only synchronous nature of the underlying services. In general that is not the case. Advantages of the
event loop[1] are not taken into account. -
Software entropy[2] is very high.Pyramid of doom[3] consisting ofif-elsestatements is just one example.
What can we do about it?
For starters, we can reduce and simplify the pyramid by short-circuiting if statements.
function processUserRequest(userId, requestData) {
const user = database.getUser(userId);
if (user === null) {
return { error: "User not found" };
}
const permissions = auth.checkPermissions(user);
if (permissions === null) {
return { error: "Unauthorized" };
}
const validatedData = validator.validate(requestData);
if (validatedData === null) {
return { error: "Invalid data" };
}
const result = processor.process(validatedData);
if (result === null) {
return { error: "Processing failed" };
}
const saved = storage.save(result);
if (saved === null) {
return { error: "Storage failed" };
}
return { success: saved };
}
Does it help? Mmmm ..., a bit.
Still, this code is going to end up in a bad shape after multiple modifications because of the requirements change. (Change to async services, additional services checks, additional client validations, etc...). We need something more powerful to solve this code maintainability. But what can we use? Well, here is a quick recap of what we have so far:
- Functors - allow us to apply functions to values wrapped in a context
map: (a -> b) -> F<a> -> F<b>
- Applicative Functors - extend functors to handle functions that are also wrapped in a context
apply: F<a -> b> -> F<a> -> F<b>
- Monoids - combine values with an associative operation and an identity element
combine: (a, a) -> awith identityempty: a
Seems like we are missing something.
What we have is a problem of sequencing computations
Fetch user data ----
| |
| <---------
▼
Validate the user's permissions ---
| |
| <------------------------
|
▼
Process their request ----
| |
|<----------------
▼
Save the results
Computations have to be executed in a sequence, yet the result of the previous computation directly affects the behavior of the next one.
From our tool box, applicative functors are the closest structure we have seen so far. They combine computations whose structure is known in advance, so each branch is independent of the values produced by the others. What we are missing is something that can carry the context, then choose the next computation from the previous result. This requires a change in how we have been thinking so far. All the constructs we used before were designed to operate within a context. This time, not only do we need to operate within the context, we need to introduce effects to the context under supervised control.
Remember reader applicative? It provides same input r to all computations. That is not enough, but it gives a hook to hang a thought by. We need to be able to collect intermediate computation results and pass them down the sequence.
Writer applicative #
If the intuition is telling you that we are close, that is not by mistake. Just like there is a structure for the reader applicative, there is a related product-shaped structure: writer applicative[6]. While the reader applicative models computations that consume a shared environment, the writer applicative models computations that produce accumulated output alongside their primary result.
Categorically, the Writer W x A (where W is the "log" type and A is the value type) represents a product object in the category. Unlike Reader's exponential structure, Writer uses the cartesian product to pair computations with their accumulated context. The Writer functor W x (-) maps:
- Objects:
A -> W x A(pairs of log and value) - Morphisms:
f: A -> Bmaps toid_W × f: W × A -> W × B
The Writer Applicative builds upon this product structure with operations that respect the underlying monoid structure of W:
-
Pure (Unit/Return):
η: Id -> (W x (-))- Categorically:
η_A: A -> W x A - Implementation:
η_A(a) = (mempty_W, a)using the monoid identity - This lifts values into the product while providing the neutral element for the log
- Categorically:
-
Apply (Monoidal Product):
⊛: (W x (A -> B)) x (W x A) -> W x B- Uses the monoid operation
⊕: W x W -> Wto combine logs - Combined with function application on the value components
- Implementation:
((w₁, f) ⊛ (w₂, x)) = (w₁ ⊕ w₂, f(x))
- Uses the monoid operation
The Categorical Insight - Monoidal Structure #
What makes Writer Applicative categorically significant is its demonstration of how monoidal structure supports computational patterns. Essentially, log type W must form a monoid in the category of values:
- Associativity:
(w₁ ⊕ w₂) ⊕ w₃ = w₁ ⊕ (w₂ ⊕ w₃)ensures log combination is well-defined - Identity:
mempty ⊕ w = w ⊕ mempty = wprovides the neutral element for pure computations - Compatibility with mapping:
fmapchanges only the value part ofWriter W A; it leaves the accumulatedWvalue alone
Independent Composition from Monoidal Structure #
The Writer Applicative combines independent computations by combining their logs with the monoid operation. If W is not commutative, the order is still meaningful and the applicative instance preserves a fixed left-to-right order. If W is commutative, then w₁ ⊕ w₂ = w₂ ⊕ w₁, so the accumulated log does not depend on order. That commutativity can make parallel evaluation easier to justify, but applicative structure by itself means independent composition.
Product and Reader Shape #
Writer pairs a value with accumulated context, while Reader consumes shared context as input:
- Writer:
W × A - Reader:
W -> A
In a cartesian closed category, products and function spaces are connected by currying and uncurrying:
Hom(W × A, B) ≅ Hom(A, B^W)
We will name the general pattern later. For now, the practical point is enough: Writer carries context alongside the result, while Reader waits for context before producing a result.
Writer Examples #
data Writer w a = Writer a w deriving (Show)
-- Writer is a Functor
instance Functor (Writer w) where
fmap f (Writer a w) = Writer (f a) w
-- Writer is an Applicative (requires Monoid constraint for log type)
instance Monoid w => Applicative (Writer w) where
pure a = Writer a mempty
Writer f w1 <*> Writer a w2 = Writer (f a) (w1 <> w2)
-- Helper function to create logged values
logged :: a -> [String] -> Writer [String] a
logged value logs = Writer value logs
-- Example functions that produce logged results
addWithLog :: Int -> Writer [String] Int
addWithLog x = logged x ["Added " ++ show x]
multiplyWithLog :: Int -> Writer [String] Int
multiplyWithLog x = logged (x * 2) ["Multiplied " ++ show x ++ " by 2"]
-- Pure applicative computation
main :: IO ()
main = do
let w1 = addWithLog 3
w2 = addWithLog 5
-- Using applicative operators to combine logged computations
result = (+) <$> w1 <*> w2
-- More complex applicative combination
complexResult = (\a b c -> a + b * c) <$> addWithLog 10 <*> multiplyWithLog 2 <*> addWithLog 3
case result of
Writer value logs -> do
putStrLn $ "Simple result: " ++ show value -- 8
putStrLn $ "Logs: " ++ show logs -- ["Added 3", "Added 5"]
case complexResult of
Writer value logs -> do
putStrLn $ "Complex result: " ++ show value -- 24 (10 + 4 * 3)
putStrLn $ "All logs: " ++ show logs -- ["Added 10", "Multiplied 2 by 2", "Added 3"]
type Writer<A> = { value: A; log: string[] };
function pure<A>(value: A): Writer<A> {
return { value, log: [] };
}
function apply<A, B>(wf: Writer<(a: A) => B>, wa: Writer<A>): Writer<B> {
return {
value: wf.value(wa.value),
log: [...wf.log, ...wa.log]
};
}
function addLog(x: number): Writer<number> {
return { value: x, log: ["Added " + x] };
}
const w1 = addLog(3);
const w2 = addLog(5);
const wf = pure((a: number) => (b: number) => a + b);
const result = apply(apply(wf, w1), w2);
console.log(result.value); // 8
console.log(result.log); // ["Added 3", "Added 5"]
using System;
using System.Collections.Generic;
public class Writer<A>
{
public A Value { get; }
public List<string> Log { get; }
public Writer(A value, List<string> log)
{
Value = value;
Log = log;
}
public static Writer<A> Pure(A value) => new Writer<A>(value, new List<string>());
public static Writer<B> Apply<B>(Writer<Func<A, B>> wf, Writer<A> wa)
{
var combinedLog = new List<string>(wf.Log);
combinedLog.AddRange(wa.Log);
return new Writer<B>(wf.Value(wa.Value), combinedLog);
}
}
class Program
{
static Writer<int> AddLog(int x) => new Writer<int>(x, new List<string> { $"Added {x}" });
static void Main()
{
var w1 = AddLog(3);
var w2 = AddLog(5);
// Create a function that takes two integers and adds them
var addTwoNumbers = new Writer<Func<int, int>>(x => x + 5, new List<string> { "Created add function" });
var result = Writer<int>.Apply<int>(addTwoNumbers, w1);
Console.WriteLine(result.Value); // 8
Console.WriteLine(string.Join(", ", result.Log)); // Created add function, Added 3
// More complex example: manual currying simulation
var w1PlusFive = AddLog(3);
var w2PlusFive = AddLog(5);
var addFunc = new Writer<Func<int, int>>(x => x + w2PlusFive.Value, w2PlusFive.Log);
var finalResult = Writer<int>.Apply<int>(addFunc, w1PlusFive);
Console.WriteLine(finalResult.Value); // 8 (3 + 5)
Console.WriteLine(string.Join(", ", finalResult.Log)); // Added 5, Added 3
}
}
Monad #
Monads build upon all these concepts to solve a fundamental problem: sequencing computations that produce wrapped values. While functors let us transform wrapped values and applicative functors let us combine wrapped functions with wrapped values, monads let us chain operations where each step produces a new wrapped value using a flatMap.
flatMap (also called bind or >>=) operation:
flatMap: M<a> -> (a -> M<b>) -> M<b>
This allows us to:
- Unwrap a value from its context
- Apply a function that produces a new wrapped value
- Flatten the result to avoid nested wrapping
Hence, monads handle common programming challenges:
- Error handling: Chain operations that might fail without nested if-checks
- Asynchronous operations: Sequence async calls without callback hell
- State management: Thread state through computations
- Null safety: Work with potentially absent values safely
- Logging and side effects: Add behavior without cluttering core logic
Formal definition #
A monad in category theory[4] is a triple (M, η, μ) where:
M: C -> Cis an endofunctor on categoryCη: Id_C ⟹ Mis a natural transformation from the identity functor to the monad functor (called unit or return)μ: M ∘ M ⟹ Mis a natural transformation (called multiplication or join) - flattering two layers
Where:
Id_C : C -> C- identity functor(M ∘ M)(A) = M(M(A))- appliesMtwice
These must satisfy the monad laws
Monad laws #
-
Left Unit Law (Left Identity)
μ_A ∘ η_{M(A)} = id_{M(A)}M(A) ----η_{M(A)}----> M(M(A)) ----μ_A----> M(A) | ^ | | +-------------------id_{M(A)}------------------+This diagram shows that wrapping
M(A)with unitηand then flattening with multiplicationμis equivalent to doing nothing (identity). The compositionμ ∘ ηacts as the identity on monadic values. -
Right Unit Law (Right Identity)
μ_A ∘ M(η_A) = id_{M(A)}M(A) ----M(η_A)----> M(M(A)) ----μ_A----> M(A) | ^ | | +-------------------id_{M(A)}------------------+This diagram shows that applying the functor
Mto unitηand then flattening with multiplicationμis also equivalent to identity. The compositionμ ∘ M(η)acts as the identity on monadic values. -
Associativity Law
μ_A ∘ M(μ_A) = μ_A ∘ μ_{M(A)}M(M(M(A))) ----M(μ_A)----> M(M(A)) ----μ_A----> M(A) | ^ | | | μ_{M(A)} | v | M(M(A)) -------------------------μ_A--------------+This diagram shows that when flattening a triple-nested monad
M(M(M(A))), it doesn't matter whether we flatten the inner layers first (M(μ_A)thenμ_A) or the outer layers first (μ_{M(A)}thenμ_A). Both paths yield the same result.
Programming Definition #
Let's build up the full definition step by step.
-
Start with a type constructor
First, we need a way to wrap values in a computational context:
-- Haskell: A type constructor that takes one type and produces another -- Maybe :: * -> * Maybe Int -- wraps Int values that might be missing Maybe String -- wraps String values that might be missing// TypeScript: Generic types that add context type Maybe<A> = A | null// C#: Generic classes that add context class Maybe<A> { } // wraps values that might be missing class Task<A> { } // async context class List<A> { } // multiple values context -
Add a way to put values into the context
Next, we need a function to lift ordinary values into our wrapped type:
-- Haskell: return/pure lifts values into the monad return :: a -> Maybe a return 5 = Just 5 -- wrap the value 5 return "hello" = Just "hello"Raw values like
5or"hello"don't carry information about potential failure, async operations, or other computational effects. The type constructor adds the context.// TypeScript: A function that wraps values function pureMaybe<A>(value: A): Maybe<A> { return value // In Maybe, this just returns the value } // For Promise: function purePromise<A>(value: A): Promise<A> { return Promise.resolve(value) }// C#: Static methods that wrap values static Maybe<A> Pure<A>(A value) { return new Maybe<A>(value); // wrap the value } // For Task: static Task<A> Pure<A>(A value) { return Task.FromResult(value); // async context } -
Chain operations that return wrapped values
We need to chain functions where each function takes an unwrapped value but returns a wrapped value:
-- Problem: We have functions that return wrapped values safeDivide :: Int -> Int -> Maybe Int safeDivide x 0 = Nothing safeDivide x y = Just (x `div` y) safeDivideBy :: Int -> Int -> Maybe Int safeDivideBy divisor value = safeDivide value divisor -- How do we chain: Maybe Int -> (Int -> Maybe Int) -> Maybe Int? -- We need bind (>>=)The bind operation
>>=solves this by:- Checking the context (is the value present? did the computation succeed?)
- Extracting the value if the context is valid
- Applying the function to get a new wrapped result
- Returning the result without double-wrapping
-- Haskell: bind operation (>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b Nothing >>= f = Nothing -- short-circuit on failure (Just x) >>= f = f x -- apply function to unwrapped value// TypeScript: flatMap/bind operation function flatMap<A, B>(ma: Maybe<A>, f: (a: A) => Maybe<B>): Maybe<B> { if (ma === null) { return null // short-circuit on failure } return f(ma) // apply function to unwrapped value }// C#: Bind/SelectMany operation static Maybe<B> Bind<A, B>(Maybe<A> ma, Func<A, Maybe<B>> f) { if (ma.Value == null) { return new Maybe<B>(null); // short-circuit on failure } return f(ma.Value); // apply function to unwrapped value } // Extension method for fluent syntax static Maybe<B> SelectMany<A, B>(this Maybe<A> ma, Func<A, Maybe<B>> f) { return Bind(ma, f); } -
See how operations work together
Now we can chain operations smoothly:
-- Chaining operations with bind computation = return 20 -- Maybe Int >>= safeDivideBy 4 -- Maybe Int >>= safeDivideBy 2 -- Maybe Int -- Result: Just 2 -- Without monads, we'd need nested pattern matching: -- case safeDivide 20 4 of -- Nothing -> Nothing -- Just x -> case safeDivide x 2 of -- Nothing -> Nothing -- Just y -> Just y// Helper function that might fail function safeDivide(x: number, y: number): number | null { return y === 0 ? null : x / y; } // Chaining operations with flatMap const computation = flatMap(pureMaybe(20), x => flatMap(safeDivide(x, 4), y => safeDivide(y, 2))); // More readable with helper functions function divideBy(divisor: number): (value: number) => number | null { return (value: number) => safeDivide(value, divisor); } const result = flatMap(flatMap(pureMaybe(20), divideBy(4)), divideBy(2)); // Result: 2.5 // Without monads, we'd need nested null checks: // const step1 = safeDivide(20, 4); // if (step1 !== null) { // const step2 = safeDivide(step1, 2); // if (step2 !== null) { // return step2; // } // } // return null;// Helper function that might fail static Maybe<int> SafeDivide(int x, int y) { return y == 0 ? new Maybe<int>(null) : new Maybe<int>(x / y); } // Chaining operations with Bind/SelectMany var computation = Maybe<int>.Pure(20) .SelectMany(x => SafeDivide(x, 4)) .SelectMany(y => SafeDivide(y, 2)); // Result: Maybe containing 2 // Using LINQ query syntax (thanks to SelectMany) var linqResult = from x in Maybe<int>.Pure(20) from y in SafeDivide(x, 4) from z in SafeDivide(y, 2) select z; // Without monads, we'd need nested checks: // var step1 = SafeDivide(20, 4); // if (step1.Value != null) { // var step2 = SafeDivide(step1.Value, 2); // if (step2.Value != null) { // return step2; // } // } // return new Maybe<int>(null);
Complete Monad Definition #
A monad in programming[5] requires following three components working together:
- Type Constructor
M<A>: Wraps values in computational context - Unit/Return
pure: A -> M<A>: Lifts values into the context - Bind
flatMap: M<A> -> (A -> M<B>) -> M<B>: Chains context-aware operations
It's the bind that fuels everything:
- Handle the context automatically (checking for null, errors, async completion, etc.)
- Extract values safely for the next operation
- Prevent double-wrapping by flattening nested contexts
The multiplication
μand bind>>=are related by:
μ_A = join = (>>= id)m >>= f = μ_B (fmap f m)wherefmapis the functor operation ofM
Monad Laws in Programming Terms #
-
Left Identity
Wrapping a value and then binding a function should be the same as just applying the function
return a >>= f = f a -- Example with Maybe: -- return 5 >>= (\x -> Just (x * 2)) = Just (5 * 2) = Just 10 -- (\x -> Just (x * 2)) 5 = Just 10 -- Both expressions are equal// pure(a).flatMap(f) === f(a) function leftIdentityExample<A, B>(a: A, f: (x: A) => Maybe<B>): boolean { const left = flatMap(pure(a), f); const right = f(a); return JSON.stringify(left) === JSON.stringify(right); } // Example usage: const addTen = (x: number) => x !== null ? x + 10 : null; console.log(leftIdentityExample(5, addTen)); // true// Pure(a).Bind(f) == f(a) static bool LeftIdentityExample<A, B>(A a, Func<A, Maybe<B>> f) { var left = Maybe<A>.Pure(a).Bind(f); var right = f(a); return left.Equals(right); } // Example usage: Func<int, Maybe<int>> addTen = x => Maybe<int>.Pure(x + 10); Console.WriteLine(LeftIdentityExample(5, addTen)); // True -
Right Identity
Binding return/pure to a monadic value should leave it unchanged
-- m >>= return = m -- Example with Maybe: -- Just 5 >>= return = Just 5 -- Nothing >>= return = Nothing -- Both preserve the original value// ma.flatMap(pure) === ma function rightIdentityExample<A>(ma: Maybe<A>): boolean { const left = flatMap(ma, pure); const right = ma; return JSON.stringify(left) === JSON.stringify(right); } // Example usage: console.log(rightIdentityExample(42)); // true console.log(rightIdentityExample(null)); // true// ma.Bind(Pure) == ma static bool RightIdentityExample<A>(Maybe<A> ma) { var left = ma.Bind(Maybe<A>.Pure); var right = ma; return left.Equals(right); } // Example usage: Console.WriteLine(RightIdentityExample(Maybe<int>.Pure(42))); // True Console.WriteLine(RightIdentityExample(new Maybe<int>(null))); // True -
Associativity
The grouping of binding operations should not matter
-- (m >>= f) >>= g = m >>= (\x -> f x >>= g) -- Example with Maybe: -- Left side: (Just 5 >>= double) >>= toString -- Right side: Just 5 >>= (\x -> double x >>= toString) -- Both should produce the same result --- where double x = Just (x * 2) toString x = Just (show x)// flatMap(flatMap(ma, f), g) === flatMap(ma, x => flatMap(f(x), g)) function associativityExample<A, B, C>( ma: Maybe<A>, f: (x: A) => Maybe<B>, g: (y: B) => Maybe<C> ): boolean { const left = flatMap(flatMap(ma, f), g); const right = flatMap(ma, x => flatMap(f(x), g)); return JSON.stringify(left) === JSON.stringify(right); } // Example usage: const double = (x: number) => x !== null ? x * 2 : null; const toString = (x: number) => x !== null ? x.toString() : null; console.log(associativityExample(5, double, toString)); // true// ma.Bind(f).Bind(g) == ma.Bind(x => f(x).Bind(g)) static bool AssociativityExample<A, B, C>( Maybe<A> ma, Func<A, Maybe<B>> f, Func<B, Maybe<C>> g) { var left = ma.Bind(f).Bind(g); var right = ma.Bind(x => f(x).Bind(g)); return left.Equals(right); } // Example usage: Func<int, Maybe<int>> double = x => Maybe<int>.Pure(x * 2); Func<int, Maybe<string>> toString = x => Maybe<string>.Pure(x.ToString()); Console.WriteLine(AssociativityExample(Maybe<int>.Pure(5), double, toString)); // True
Monadic Do-Notation #
Some languages provide syntactic sugar for monadic compositions (do-notation):
-- Instead of: m1 >>= \x -> m2 >>= \y -> return (f x y)
do x <- m1
y <- m2
return (f x y)
While TypeScript doesn't have built-in do-notation, we can simulate it using generator functions and async/await patterns:
// Type definition for Maybe
type Maybe<T> = T | null;
// Helper functions
function pure<T>(value: T): Maybe<T> {
return value;
}
function safeDivide(x: number, y: number): Maybe<number> {
return y === 0 ? null : x / y;
}
// Option 1: Generator-based do-notation simulation
function runDo<TFrom, T, TNext>(generatorFn: () => Generator<Maybe<TFrom>, T, TNext>): Maybe<T> {
const generator = generatorFn();
let current = generator.next();
while (!current.done) {
const maybeValue = current.value;
if (maybeValue === null) {
return null; // Short-circuit on failure
}
current = generator.next(maybeValue as TNext);
}
return current.value;
}
// Usage example:
const doExample = runDo(function*() {
const x: number = yield safeDivide(20, 4); // Extract value from Maybe
const y: number = yield safeDivide(x, 2); // Chain the next operation
return y * 3; // Final computation
});
console.log("Generator example result:", doExample); // Output: 7.5
C# has LINQ query syntax which provides a natural do-notation-like experience through SelectMany:
using System;
// Type definition for Maybe
public class Maybe<T>
{
public T? Value { get; }
public bool HasValue { get; }
public Maybe(T? value)
{
Value = value;
HasValue = value != null;
}
public static Maybe<T> Pure(T value) => new Maybe<T>(value);
public static Maybe<T> None() => new Maybe<T>(default(T));
public Maybe<U> Bind<U>(Func<T, Maybe<U>> f)
{
return HasValue ? f(Value!) : Maybe<U>.None();
}
// Required for LINQ query syntax
public Maybe<U> SelectMany<U>(Func<T, Maybe<U>> f)
{
return Bind(f);
}
// Required for LINQ query syntax with projection
public Maybe<V> SelectMany<U, V>(Func<T, Maybe<U>> f, Func<T, U, V> projection)
{
return Bind(x => f(x).Bind(y => Maybe<V>.Pure(projection(x, y))));
}
}
class Program
{
static Maybe<int> SafeDivide(int x, int y)
{
return y == 0 ? Maybe<int>.None() : Maybe<int>.Pure(x / y);
}
static void Main()
{
// Option 1: LINQ query syntax (natural do-notation)
var linqResult =
from x in Maybe<int>.Pure(20)
from y in SafeDivide(x, 4)
from z in SafeDivide(y, 2)
select z * 3;
Console.WriteLine($"LINQ result: {(linqResult.HasValue ? linqResult.Value.ToString() : "null")}");
// Output: LINQ result: 6
}
}
This notation makes sequential monadic computations read like imperative code while maintaining all properties/laws of the monad.
Category of Endofunctors #
"Wait, what? Why are we suddenly talking about categories again? Have not we explored categories in every single possible way that was sufficient to build a foundation?
- Regular categories
- Cartesian closed categories
- Arrow categories
- Category of endofunctors
- Monoidal categories
It's a fair question. The answer is "not yet". What is about to happen looks like this.
Imagine you're at a magic show. The magician has been pulling rabbits out of hats, making coins disappear, and doing all sorts of impressive tricks. Then suddenly, they start explaining the physics of electromagnetic fields. You're thinking, "Dude, I just wanted to see some magic tricks!"
Well, monads ARE the magic trick, and the category of endofunctors is the secret mechanism behind the curtain. It's like finding out that your favorite superhero's powers actually come from a very sophisticated piece of alien technology that operates on principles of advanced theoretical physics.
So why do we care?
Once you see what monads really are, a whole bunch of mysterious monad behaviors suddenly make perfect sense.
The collection of all endofunctors on a category
Cforms its own category, denotedEnd(C)or[C, C].
and that category is a monoidal one. Now, let's take an opposite look at the monoid. This time from the monoidal category point of view:
A monoid object in the monoidal category End(C) consists of:
- Monoid Object: An endofunctor
M: C -> C - Multiplication: A natural transformation
μ: M ∘ M ⟹ M- For each object
AinC, we haveμ_A: M(M(A)) -> M(A). This is a natural transformation from the functor compositionM ∘ MtoM - Computationally, this flattens nested monadic contexts
- Naturality condition:
μ_B ∘ M(M(f)) = M(f) ∘ μ_Afor anyf: A -> B
- For each object
- Unit: A natural transformation
η: Id_C ⟹ M- For each object
AinC, we haveη_A: A -> M(A). Categorically, this is a natural transformation from the identity functor toM. Computationally, this lifts values into the monadic context - Naturality condition:
M(f) ∘ η_A = η_B ∘ ffor anyf: A -> B
- For each object
Satisfying the monoid laws:
- Associativity:
μ ∘ (μ * Id_M) = μ ∘ (Id_M * μ) - Left Unit:
μ ∘ (η * Id_M) = Id_M - Right Unit:
μ ∘ (Id_M * η) = Id_M
Can you see it? This is exactly the monad definition.
Monoid object in End(C) |
Monad on C |
|---|---|
| Object of the monoidal category | Endofunctor M: C -> C |
Monoidal product ∘ |
Nested context M ∘ M, meaning M(M(A)) |
Unit object Id_C |
Plain values in C, unchanged by Id_C |
Unit map η: Id_C ⟹ M |
η_A: A -> M(A), also called return or pure |
Multiplication map μ |
μ_A: M(M(A)) -> M(A), or join |
| Monoid laws | Monad laws: left unit, right unit, and associativity |
Think of it this way: You know how a regular monoid is just "a thing that you can combine with itself, and there's a boring 'do nothing' version"? Like how you can add numbers (combination) and zero is the "do nothing" element?
Well, endofunctors can be combined too (by composition), and there's an identity endofunctor that does nothing. So endofunctors form their own little mathematical society with its own rules and operations. And monads? They're just the VIP members of this society - the ones with extra special powers (unit and multiplication operations) that make them particularly useful for sequencing computations.
If functors are like different paintbrushes, then the category of endofunctors is like the entire art studio where all the brushes live, complete with rules about how brushes can be combined and transformed. Monads are those fancy, Swiss Army knife brushes that not only paint but also have built-in erasers and color blenders.
A monad is a monoid object in the monoidal category of endofunctors.
Conclusion #
Monads extend the abstractions we have built so far by adding sequencing. A functor maps a function over a value in context. An applicative combines independent contextual values. A monad lets one contextual computation choose the next contextual computation from the value it produced.
That is the key difference:
- Applicative: the shape of the computation is known in advance.
- Monad: the next step can depend on the previous result.
In practical terms, this gives us a disciplined way to model error handling, optional values, async workflows, state, logging, and other effects without losing composability.
Source code #
Reference implementation (opens in a new tab)