Back to blog
Aug 08, 2024
7 min read

Monads are like burritos

Have you ever tried explaining Bitcoin to somebody? How about Monads?

Monads are the tortillas of the burrito universe. Sometimes you’ve got a nice flour tortilla. Other times, it’s wheat.

”For a monad m, a value of type m a represents having access to a value of type a within the context of the monad.” — C. A. McCann

burrito

Monads: Wrapping Values Like Tortillas

So you’ve got your burrito. It’s stuffed with beans, rice, meat, the works. That’s your monad. It wraps everything up nicely so you don’t make a mess of your hands. In programming, monads wrap your values with context, making them manageable. We’re talking things like Maybe or Option wrapping values that might not be there, Result wrapping computations that can fail, or IO wrapping side effects.

Burritos are awesome. You don’t need cutlery, just monads to deal with all the inside goop without getting your hands dirty.

Lift Operations

Suppose your burrito needs a little something extra. You don’t just throw the hot sauce in there raw. You need a lift operation. It takes a plain value and brings it into the monadic context.

Think of it as injecting extra hot sauce right into your burrito without unwrapping it. You’re taking a boring 2, lifting it into a Maybe 2, and now it’s monad-ready. It’s still wrapped, neat, tasty.

-- Lift a value into a Maybe monad
liftMaybe :: a -> Maybe a
liftMaybe x = Just x

-- Use the lifted value in a computation
addOne :: Maybe Int -> Maybe Int
addOne m = fmap (+1) m  -- Adds 1 if the value exists, stays Nothing if it doesn't

main = print (addOne (liftMaybe 5))  -- Output: Just 6

No mess, no fuss.

Catamorphisms: Eating the Burrito Inside Out

Remember when you were a kid and thought it was funny to just rip it open and eat it inside out. That’s catamorphisms. It’s when you take the monad, lay it bare, and start scooping the insides out. You’re consuming that wrapped-up context and turning it into something simple, something manageable. It’s like taking all that stuff inside the burrito and putting it on a plate.

Let’s make it real:

-- A function to consume Maybe and provide a default
extractMaybe :: Maybe a -> a -> a
extractMaybe Nothing defaultValue = defaultValue
extractMaybe (Just x) _ = x

-- Applying the catamorphic scoop
main = do
    print (extractMaybe (Just 42) 0)  -- Output: 42
    print (extractMaybe Nothing 0)    -- Output: 0

This is it. We’ve taken our wrapped value, lifted it out with the catamorphic scoop, and boom — no more monad, just the tasty stuff inside, ready to be used. We’ve extracted the value while keeping control of all the chaos.

Why You Should Care: Monads Make It Clean

You could do all of this manually—check every step, handle every error, unwrap every value—but why would you? That’s like trying to make your own tortilla every time you want a burrito. Monads do the heavy lifting for you, keeping your code clean, organized, and free from all the nasty side effects.

Think of monads as the infrastructure that keeps the workflow tight. They handle optional values (Maybe), errors (Either), and side effects (IO). You don’t have to manually unwrap everything or check every little thing—just map, bind, lift, and scoop. That’s it.

Other burritos

No built-in definitions in languages like Rust, Go, Python, JavaScript - but there are monad-like patterns.

1. Error Handling in Command-Line Tools

Imagine writing a script that reads a config file, parses it, and performs some operations based on its contents. This process can fail at multiple points: the file might not exist, parsing might fail, or the configuration might be invalid. Using monads like Result or Either, you can chain these operations while keeping error handling explicit and manageable.

Example: Rust CLI Tool with Error Handling

use std::fs::File;
use std::io::{self, Read};

// Function to read a file and return the contents or an error
fn read_file(path: &str) -> Result<String, io::Error> {
    let mut file = File::open(path)?; // Attempts to open the file, propagates error on failure
    let mut contents = String::new();
    file.read_to_string(&mut contents)?; // Reads the file contents
    Ok(contents)
}

fn main() {
    match read_file("config.txt") {
        Ok(contents) => println!("File contents: {}", contents),
        Err(e) => eprintln!("Error reading file: {}", e),
    }
}

Result is used to handle potential failures at each step (File::open and read_to_string). Instead of deeply nested error checks, the ? operator propagates errors up the chain, allowing the main logic to remain clean and readable.

2. Asynchronous Programming with Promises/Futures

Handling asynchronous operations, especially in a web application or service, can lead you to callback hell or deeply nested logic. Monads like Future in JavaScript can chain asynchronous operations in a linear, readable way.

Example: JavaScript Fetch API with Promises

// Function to fetch data from an API and handle errors gracefully
function fetchData(url) {
  return fetch(url)
    .then(response => {
      if (!response.ok) {
        return Promise.reject('Error fetching data');
      }
      return response.json();
    })
    .then(data => {
      console.log('Data:', data);
    })
    .catch(error => {
      console.error('Error:', error);
    });
}

fetchData('https://api.example.com/data');

Using Promise allows chaining of operations (then) while managing errors in a clean, declarative manner (catch). Each step is wrapped, and failures propagate naturally, keeping the code flow straightforward.

3. State Management in Functional Programming

Monads like State are used to handle mutable state in a purely functional way. Imagine you’re writing a game or simulation where state transitions are frequent. Using a state monad allows you to handle state changes without mutating global variables, keeping your functions pure and easy to test.

Example: Game State Management in Haskell

-- A simple game state with a player's position
type GameState = (Int, Int) -- (x, y)

-- State monad to handle game state transitions
moveRight :: State GameState ()
moveRight = modify (\(x, y) -> (x + 1, y))

moveUp :: State GameState ()
moveUp = modify (\(x, y) -> (x, y + 1))

-- Running the game logic with initial state
main :: IO ()
main = do
    let initialState = (0, 0)
    let finalState = execState (moveRight >> moveUp >> moveRight) initialState
    print finalState  -- Output: (2, 1)

The State monad handles the sequence of operations that modify the game state. Each move is defined as a pure function that doesn’t mutate state directly; state transitions are managed by the monad.

4. Input/Output Management in Scripts

When writing scripts that need to perform IO operations (reading files, writing logs, interacting with databases), you can end up with tightly coupled code full of side effects. Using the IO monad in Haskell (or similar constructs in other languages) allows you to sequence IO operations in a structured and predictable way.

Example: Haskell Script for File Processing

-- A simple IO monad usage to read, process, and write data
main :: IO ()
main = do
    contents <- readFile "input.txt"  -- Read input file
    let processed = map toUpper contents  -- Process the data
    writeFile "output.txt" processed  -- Write to output file
    putStrLn "File processed successfully."

The IO monad sequences the actions without exposing the internals of how side effects are managed. This keeps the side-effectful parts of the code well-structured and easy to follow.

5. Dependency Management and Validation in Applications

When building a script or application that needs to validate dependencies or configurations (e.g., checking environment variables, file existence, permissions), monads like Maybe or Either help cleanly express these checks.

Example: Configuration Validation in Python with Optional Values

from typing import Optional

def get_env_variable(name: str) -> Optional[str]:
    # Retrieves an environment variable, returning None if it doesn't exist
    value = os.getenv(name)
    return value if value else None

def main():
    # Use Optional to handle missing environment variables cleanly
    db_url = get_env_variable("DATABASE_URL")
    if db_url:
        print(f"Connecting to database at {db_url}")
    else:
        print("Error: DATABASE_URL is not set")

main()

Using Optional values handles the missing configuration gracefully, avoiding hard crashes and keeping error handling explicit and clear.

So googling monads wasn’t a complete waste of time. Stuff like error handling can be frustrating, but at least with monads you can keep your hands clean.