Categories
AuShadha Haskell Open Source & Programming

Category Theory Meets Gait Lab (>>=) Learning Haskell Monads


Ever read “A monad is just a monoid in the category of endofunctors” and wanted to throw your computer out the window?

I did. Every tutorial I opened tried to pick the lock with abstract category theory or bizarre analogies about burritos. None of it clicked. So, I threw out the tutorials and rebuilt the concept from the only place it makes sense to me: the lab floor.

In Part 1 of my new series, I break down how we escaped the imperative “Pyramid of Doom” while building a markerless gait-analysis pipeline for children with cerebral palsy. No abstract math—just the messy reality of dropped sensor packets, unreliable camera feeds, and how Haskell’s Functors, Applicatives, and Monads elegantly rescued our codebase.

#Haskell #FunctionalProgramming #Monads #CategoryTheory #GaitAnalysis #AuShadha #HealthTech #SoftwareEngineering


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?”

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.


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.


That exact problem handed me my first tool: the
Functor.

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.


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.

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.”

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 to Nothing.
  • `<-` 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 about return again, maybe, in Part 3. Pun intended 😉 )


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.

dreaswar's avatar

By dreaswar

Consultant Paediatric Orthopaedic & Spine Surgeon,
Coimbatore, Tamil Nadu & Palakkad, Kerala
India

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.