Monads Exploration

Fri Dec 15 2023

A monad is a design pattern for writing code. Extremely powerful and approachable despite its complex math appearance.

Starting code

function square(x: number): number {
	return x * x;
}

function addOne(x: number): number {
	return x + 1;
}

calling addOne(square(2)) would return 5

if we wanted to return logs in each function (for transparency) the result would look something like this:

addOne(square(2)) => {
	result: 5,
	logs: [
		"Squared 2 to get 4",
		"Add 1 to 4 to get 5"
	]
}

In order to support this pattern/logic we’ll define a return type:

interface NumberWithLogs {
	result: number,
	logs: string[]
}

Then update original functions to use NumberWithLogs return type:

function square(x: number): number {
	return{
		result: x * x,
		logs: [
			`Squared ${x} to get ${x * x}`
		]
	};
}

function addOne(x: number): number {
	return {
		result: x + 1,
		logs: [
			`Add 1 to ${x} to get ${x + 1}`
		]
	};
}

This is a good starting point for adding logs to the functions. More work needs to be done to allow the functions to append logs as calls are nested. We can use logic similar to [[Constructors]] to make this logic work seamlessly.

// Helps numbers ender the NumberWithLogs ecosystem
function wrapWithLogs(x: number): NumberWithLogs {
	return {
		result: x,
		logs: []
	}
}

// Now we just need to update the original functions to accept NumerWithLogs as input and make sure the return values are concatenating logs before returning
function square(x: NumberWithLogs): number {
	return{
		result: x.result * x.result,
		logs: x.logs.concat([
			`Squared ${x.result} to get ${x.result * x.result}`
		])
	};
}

function addOne(x: NumberWithLogs): number {
	return {
		result: x.result + 1,
		logs: x.logs.concat([
			`Add 1 to ${x.result} to get ${x.result + 1}`
		])
	};
}

Now we can call addOne(square(x)) or square(addOne(x)) and have the correct return value + associated logs.

New call pattern: addOne(square(wrapWithLogs(2)))


There is a decent amount of repeat code in the refactored functions, we can abstract away some of the logic to make it easier to read.

function square(x: NumberWithLogs): NumberWithLogs {
	const newNumberWithLogs = {
		result: x.result * x.result,
		logs: [`Squared ${x.result} to get ${x.result * x.result}`]
	}
	return{
		result: newNumberWithLogs.result,
		logs: x.logs.concat(newNumberWithLogs.logs)
	};
}

// now that we've refactored, lets create a helper function to make things even easier
// transform: function to apply to a number which returns a new NumberWithLogs object (this is where we can pass in square(), or addOne())
function runWithLogs(
	input: NumberWithLogs, 
	transform: (_: number) => NumberWithLogs
): NumberWithLogs {
	const newNumberWithLogs = transform(input.result)
	return {
		result: newNumberWithLogs.result,
		logs: input.logs.contat(newNumberWithLogs.logs)
	}
}

// because of this helper function, we no longer need addOne or Square to do log concatenation anymore.
function square(x: number): NumberWithLogs{
	return {
		result: x * x,
		logs: [`Squared ${x.result} to get ${x.result * x.result}`]
	}
}

function addOne(x: number): NumberWithLogs{
	return {
		result: x + 1,
		logs: [`Add 1 to ${x.result} to get ${x.result + 1}`]
	}
}

Now we can call the functions in any order we want, get the associated logs for the transformations, and extend this logic to any future functions we introduce.

// Example usage
const a = wrapWithLogs(5)
const b = runWithLogs(a, square)
const c = runWithLogs(b, addOne)

This centralized logic + functions is a Monad!

What is a monad?

A design pattern that allows you to chain operations while managing busy work (concatenating of logs) in a centralized location.

All monads have 3 components:

  1. Wrapper type: a wrapper of some sort that determines the type of the monad (NumberWithLogs from example above)
  2. Wrap function: a function that takes normal (primitive) values and transforms them into the wrapper type (wrapWithLogs from example above). This is sometimes called Return, Pure, or Unit.
  3. Run function: a function that accepts a wrapper type and a transform function (function that accepts an unwrapped type and returns the wrapper type). This is sometimes called Bind, flatMap or >>=. In the example above, this was runWithLogs

Why use monads?

They allow for one-time definition of more-complex logic to be handled behind the scenes.

Common uses for monads