PART 1 OF 6
Escaping the Imperative Pyramid of Doom
How a cerebral-palsy gait lab finally taught me what a Haskell Monad actually is
It started with one sentence. I had been teaching myself Haskell for weeks and making real progress — I had wrapped my head around currying and partial application, a milestone in its own right. Then, reading about the concept everyone warned was the final boss of the language – The Monad, I walked straight into this:
“A monad is just a monoid in the category of endofunctors, what’s the problem?”
— the sentence that stopped me cold
And I stopped. Not paused — stopped. Every word in it was a door into another locked room: monoid, category, endofunctor. Every tutorial I opened tried to pick those locks with even more abstract category theory, or with bizarre analogies about burritos. None of it clicked.
I didn’t need a burrito. I needed the Monad grounded in my reality: the messy, sweat-soaked, packet-dropping world of building a clinical gait-analysis pipeline for my cerebral-palsy clinic. I was building a software to analyse gait in children with cerebral palsy, my latest addition to the AuShadha Project. It was a 15 year dream, full of incremental learnings, stoppages, hurdles… I thought I had not hit another hurdle.
So I threw out the tutorials which were abstract, un-relatable and rebuilt the idea from the only place it ever makes sense to me — the lab floor.
This is Part 1 of a six-part journey. Over the series I’ll trace the Monad from a practical headache (this part), back to its 1950s mathematical origins (Part 2), down into its formal machinery (Parts 3 and 4), and finally through the infamous one-line definition that started this whole obsession (Part 5), before tying off the loose ends of Semigroups and Totality (Part 6).
We start where I started: drowning.
The premise: the messy reality of clinical data
Picture the setup.
A child with cerebral palsy walks on a gait lab path. I’m capturing markerless computer-vision joint angles at 120 frames per second and simultaneously ingesting high-frequency EMG from surface electrodes.
In a perfect, theoretical world, the data flows beautifully. I write a tidy function to calibrate a raw angle by adding a 5-degree offset:
--HASKELL
calibrateAngle :: Float -> Float
calibrateAngle rawAngle = rawAngle + 5.0
Feed it 45.0, get back 50.0. Simple.
But the clinic is not a perfect world. Wires come loose. Sensors drop packets. A parent leans in and occludes the camera. Sometimes I get a reading, and sometimes I get an error, or nothing at all.
Programmatically, this scenario is a nightmare. We have to prepare and programme so defensively, preparing for the edge cases, fear, hope and pray that some edge cases that we had not envisioned before would jump in, cause a runtime error and crash the programme. The traditional imperative programmer would check the scenarios that they can envision and prepare… and hope. This is very not very dependable.
This is the first thought process and fear that Haskell forced me to unlearn: we do not use `null` or None to represent missing data. Instead we use a context — a wrapper or a box or a container. The most common one is Maybe ( There are other containers in Haskell like Either, IO etc .. ). A value sits inside the container. A Maybe Float box can have two values inside it – Nothing or Just. A value of type Maybe Float is a box that is exactly one of two things:
Just 45.0— the sensor worked; here is my wrapped reading.Nothing— the sensor dropped the packet.
And immediately I had a problem.
I couldn’t feed Just 45.0 into calibrateAngle, because that function wants a raw Float, not a Float sealed inside a box. The Haskell compiler will yell at me. I needed a safe way to reach into the box, apply the calibration if the data existed, and put it back — and if the box was empty, just pass the emptiness along without crashing.
Step 1 — Discovering the Functor
That exact problem handed me my first tool: theFunctor.
A Functor is simply any wrapper that lets you map a normal function over the data hidden inside it, using a function called fmap (or its operator <$>).
-- HASKELL
sensorReading :: Maybe Float
sensorReading = Just 45.0
calibratedReading = fmap calibrateAngle sensorReading
-- Result: Just 50.0
If the sensor had failed and handed me Nothing , fmap would have safely returned Nothing — no calibration attempted, no null-pointer exception, no crash.
More about fmap and it’s common poorer cousin – the map
It’s worth slowing down on fmap's type signature, because the whole idea is compressed into one line:
fmap :: Functor f => ( a → b ) → f a → f b
Read it aloud: give me a plain function `a → b`, and a value of type `a` wrapped inside some functor `f`, and I’ll hand back the result wrapped in that same `f`. If you’ve written Python or JavaScript, you already know one special case of this — the humble map over a list. The difference is reach. Ordinary map only knows how to walk a list; its type is pinned to one container:
-- HASKELL
map :: (a -> b) -> [a] -> [b] -- lists, and only lists
fmap :: Functor f => (a -> b) -> f a -> f b -- ANY functor
fmap is the same shape with the concrete [] generalized to any container that knows how to be mapped over — Maybe, Either, IO, a tree, even a parser. In fact, for lists the two are literally identical: fmap = map. So map isn’t a rival tool; it’s just fmap with the container hard-coded to a list.
The gait-lab payoff is concrete: the single operator <$> (the infix spelling of fmap) calibrates a reading whether it arrived as a Maybe Float from a flaky IMU, an Either String Float carrying an error reason, or an IO Float straight off the camera. I write the calibration once; fmap makes it run safely inside whichever box the reading happens to be trapped in.
Now, my instinct as a Python programmer screamed the obvious objection.
But why not just use if/else?
My immediate thought was: why can’t I just check the value with an if/else and be done with it? Why invent this whole Wrapper concept?
It comes down to scale and safety. If I use if/else for a multi-stage pipeline — read the sensor, calibrate, smooth, compute flexion — I end up with a nested pyramid that tangles clinical logic together with error-handling plumbing.
And here’s the point: the moment I try to be tidy and abstract that if/else into a reusable helper, I have accidentally reinvented the Functor.
fmap is not magic. It is just the standardized, universal version of the plumbing I was about to write by hand.
Step 2 — Applicatives and the two-sensor problem
In gait analysis I rarely care about one absolute angle; I care about the relative angle between two joints. So I need a function of two arguments — a thigh reading and a shank reading:
-- HASKELL
calculateRelativeAngle :: Float -> Float -> Float
calculateRelativeAngle thighAngle shankAngle = thighAngle - shankAngle
Both readings arrive messy, each in its own Maybe box.
When I tried fmap on just the first sensor, currying bit me: fmap applied the first argument and handed me back a function still waiting for the second argument — and it stuffed that half-applied function back inside a Maybe box. Now I had a wrapped function that needed to be applied to a wrapped value, and plain fmap couldn’t do it.
Enter the Applicative Functor and its operator <*>:
-- HASKELL
finalAngle = calculateRelativeAngle <$> thighSensor <*> shankSensor
-- Result: Just 35.0
Just as the Functor abstracted the if/else for one wrapped value, the Applicative abstracts the nested if/else for many wrapped values. If either sensor drops a packet, the whole chain short-circuits cleanly to Nothing .
A note on order of precedence in Haskell and why fmap does not work in multi-argument scenarios
finalAngle = calculateRelativeAngle <$> thighSensor <*> shankSensor
Why are there no brackets — and what runs first?
That line looks ambiguous ( again that is me, coming from Python and expecting brackets to the right) . It isn’t. Both operators in it — <$> (which is fmap) and <*> — are declared infix : the same precedence level , and left-associative. Same precedence plus left-associative means Haskell groups the line strictly left-to-right, exactly as if I had reached for parentheses myself:
-- HASKELL
finalAngle = (calculateRelativeAngle <$> thighSensor) <*> shankSensor
So the answer to the question on which runs first is : The functor step `<$>` runs first, on the left. It does not run the applicative first and then feed the result into calculateRelativeAngle.
Here is the actual two-step trace, taking thighSensor = Just 50.0 and shankSensor = Just 15.0:
Step 1 — `<$>` (fmap). calculateRelativeAngle takes two arguments, so applying it to only the first leaves a function waiting for the second. fmap puts that inside a box, producing a function trapped inside a Maybe:
HASKELL
calculateRelativeAngle <$> Just 50.0
==> Just (\shank -> 50.0 - shank) :: Maybe (Float -> Float)
That wrapped-up function is the dead-end that made fmap insufficient and forced us to reach for the Applicative in the first place.
Step 2 — `<*>` (Applicative) does the job fmap cannot: it applies a boxed function to a boxed value.
HASKELL
Just (\shank -> 50.0 - shank) <*> Just 15.0
==> Just (50.0 - 15.0) ==> Just 35.0
If either box were Nothing, the whole chain short-circuits to Nothing. Brackets aren’t required because the default left-to-right grouping is already precisely the one we want. (Function application binds tighter than any operator, so calculateRelativeAngle is a finished atom before <$> even looks at it.)
The people who chose these fixities did it deliberately: f <$> x <*> y <*> z is meant to read like an ordinary multi-argument call — left to right, no parentheses. That idiom has a name: applicative style.
Step 3 — The final boss: the Monad
I had handled wrapped data and multiple wrapped inputs. The final hurdle — the one that forced me to actually understand Monads — was this: what happens when my own function returns a wrapped value?
I wanted to validate that a computed angle wasn’t biomechanically impossible:
Please note that the function validateBiomechanics takes a Float and gives back a Maybe Float. So we cannot feed out previous data which is a Maybe container into this function as a direct argument. That is where Monadic operation comes in.
We need a way , an operator or tool, that can stand between our Maybe box and validateBiomechanics , take out the value out of the Maybe box , feed it to validateBiomechanics, accept another Maybe result from that, enclose it back in our original Maybe box out of which we took the value out and then since now we have two Maybe boxes, squish it down to just one Maybe box.
-- HASKELL
validateBiomechanics :: Float -> Maybe Float
validateBiomechanics angle =
if angle >= -10.0 && angle <= 150.0
then Just angle
else Nothing
A confusion of Maybe, Nothing, and Just
The Type vs. Constructor Epiphany
For a while I was confused about Maybe, Just, and Nothing — functions promised a Maybe Float but returned a Just or Nothing. The line that unlocked it was the split between the type level and the value level: Maybe is the broad family name that exists only for the compiler; Just and Nothing are the actual shapes of data Maybe can hold. Because the compiler knows the Maybe family has exactly two members, it uses pattern matching to mathematically prove I’ve handled every possible outcome.
Can fmap do it ?
If I fmap this over Just 35.0 , fmap pulls out the 35.0 , feeds it to the function (which returns Just 35.0 ), and dutifully puts that result back in a box — leaving me with Just (Just 35.0) . Boxes inside boxes. I needed a tool that could take a wrapped value, feed it into a function that itself returns a wrapped value, and flatten the result so the boxes don’t stack up.
Please welcome our next tool, after fmap and applicative -- join
That flattening tool is the Monad bind operator , >>= :
-- HASKELL
cleanResult = Just 35.0 >>= validateBiomechanics
-- Result: Just 35.0 (one box, not two)

fmap alone leaves nested boxes; the Monad’s job is to flatten them back to one clean layer.
That’s the whole promise of a Monad, at the intuition level. Functor maps into a box. Applicative combines boxes. Monad chains functions that return boxes, flattening as it goes. Three rungs of the same ladder, each handling a strictly harder version of one problem.

The three rungs out of the pyramid. Each tool solves a harder version of “run a plain function on data trapped in a box.”
Unveiling the syntactic sugar: do notation
Chaining >>= operators by hand gets dense fast. This is exactly the tangled, rightward-marching mess I was trying to escape — the Pyramid of Doom that a Python programmer knows intimately:

Not convinced ?
Here is the how the full code may look like once functions are chained using >>=
-- HASKELL
processPatientGait :: Maybe Float -> Maybe Float -> Maybe Float
processPatientGait rawThigh rawShank =
rawThigh >>= (\thigh ->
rawShank >>= (\shank ->
let relativeAngle = thigh - shank
in validateBiomechanics relativeAngle >>= (\validAngle ->
return validAngle)))
Count the ways it fights you. Three lambdas march off to the right — a Pyramid of Doom rebuilt out of >>= — the clinical logic that actually matters ( thigh - shank ) is buried three indents deep in plumbing, and there’s a tail of )))
you have to balance by hand.
A hidden redundancy the math can spot
Look at the innermost step:validateBiomechanics relativeAngle >> (\validAngle -> return validAngle) . Feeding a value into >>= only to immediately return it does nothing — by the right-identity law (which we will talk about in Part 3) that whole line is exactly equal to :
validateBiomechanics relativeAngle
on its own. The do block spells out the redundant final step for symmetry; the algebra says you could drop it. Monad laws aren’t decoration — they let you reason about and simplify actual pipeline code.
Every <- in the tidy version is one of these >>= (\x -> …) wrappers: the compiler generates precisely this nested-lambda tower and then mercifully hides it from me. (This is the literally what GHC performs on every do block, – more about this in Part 4)
So here is Haskell’s answer: do notation — syntactic sugar that unwinds that exact tower back into sequential-looking code, while the Monad handles all the failure branching invisibly underneath:
-- HASKELL
processPatientGait :: Maybe Float -> Maybe Float -> Maybe Float
processPatientGait rawThigh rawShank = do
thigh <- rawThigh
shank <- rawShank
let relativeAngle = thigh - shank
validAngle <- validateBiomechanics relativeAngle
return validAngle
This unlocked several “aha” moments at once:
- The
`<-`operator is just>>=in disguise. It extracts the value from the box; if the box is empty, the whole block instantly aborts toNothing. `<-`versus `let`. I use<-to unpack monadic boxes, and let for plain, unwrapped math.`return`is a trap for anyone coming from Python or C. It does not stop execution. It is just an ordinary function whose only job is to take a pure value and drop it into a default box. (We will speak aboutreturnagain, maybe, in Part 3. Pun intended 😉 )
Step 4 — Making it real with Either and IO
Maybe can only say that something failed, never why. In a clinical setting, “it failed” is not an acceptable log line. So I upgraded to the `Either` Monad, which lets me carry the reason for failure alongside the failure itself:
-- HASKELL
validateVision :: Float -> Float -> Either String Float
validateVision angle confidence =
if confidence > 0.85
then Right angle
else Left "Vision Error: Patient occluded."
Chained inside a do block, my pure math pipeline stays flawless: if the camera glitches, it returns a specific, human-readable error string instead of a crash. (This idea — a function that has a legal answer for every possible input, including failure — has a name, Totality, and it will be discussed probably in part 6)
But pure logic alone does nothing; a program with no side effects just warms up the CPU as many Haskellers proudly say. To actually touch the hardware and do something useful like announcing to the user on the monitor as an output and printing characters, I reach for the `IO` Monad, which quarantines the messy real-world boundary :
-- HASKELL
runGaitAnalysis :: IO ()
runGaitAnalysis = do
putStrLn "Starting patient gait analysis..."
rawAngle <- readCameraFeed -- messy IO boundary
visConf <- readCameraFeedConfidence -- messy IO boundary
let result = calculateMuscleForce rawAngle visConf -- pure math
case result of
Left err -> putStrLn ("FAILED: " ++ err)
Right f -> putStrLn ("SUCCESS: " ++ show f)
And there it was. The Monad wasn’t a mystical entity or an internet punchline. It was the universal adapter that let me build one elegant, predictable pipeline — managing missing data, tracking clinical errors, and safely touching physical hardware — without a single line of spaghetti.
That’s the intuition. But intuition left me with a nagging question: where did this idea come from, and why did a purely functional language need rescuing by a piece of 1950s abstract algebra in the first place? In Part 2, I trace the Monad’s improbable journey from a mathematician’s chalkboard to my clinic’s compiler.