Monads
·
8 min read
As developers, we often write similar patterns of code for handling different computational contexts: nullable/undefined-able values, collections, asynchronous operations, and error handling. While TypeScript (and virtually all imperative languages ) requires us to write different code (unless you use Effect , fp-ts , or similar functional programming libraries) for each scenario, the Monad abstraction (popularized by the Haskell programming language ) reveals the underlying unity of these patterns.
The Core Pattern
Before diving into specific examples, let’s establish what we’re looking for, that is, a pattern that allows us to chain operations together in a way that abstracts away the concrete composition mechanism between them (this is why monads are often described as “a programmable semicolon”).
Example 1: Nullable Values
In this example, the chaining mechanism consists of checking whether a value is null
or undefined
before proceeding to the next operation.
This is a common pattern in TypeScript, where we often deal with nullable values:
declare function findUser(userId: string): User | undefined;declare function getProfile(user: User): Profile | undefined;declare function formatName(profile: Profile): string;
function processUser(userId: string): string | undefined { const user = findUser(userId); if (user === undefined) return undefined;
const profile = getProfile(user); if (profile === undefined) return undefined;
const displayName = formatName(profile);
return displayName;}
We can model the same logic using a Monad-like structure. In Haskell, we often use the Maybe
type and would look like:
findUser :: String -> Maybe UsergetProfile :: User -> Maybe ProfileformatName :: Profile -> String
processUser :: String -> Maybe StringprocessUser userId = do user <- findUser userId profile <- getProfile user return (formatName profile)
Example 2: Collections
The chaining mechanism here consists of iterating over the results of one operation, and then applying the next operation to each item in the collection. In TypeScript, we might use Array.flatMap()
to achieve this:
declare function findUsers(userId: string): User[];declare function getProfiles(user: User): Profile[];declare function formatName(profile: Profile): string;
function processUsers(userIds: string[]): string[] { return userIds .flatMap(id => findUsers(id)) .flatMap(user => getProfiles(user)) .flatMap(profile => formatName(profile));}
In Haskell, we can use the List
Monad to achieve the same result:
findUsers :: String -> [User]getProfiles :: User -> [Profile]formatName :: Profile -> String
processUsers :: [String] -> [String]processUsers userIds = do userId <- userIds user <- findUsers userId profile <- getProfiles user formatName profile
Example 3: Error Handling
In this example, the chaining mechanism consists of checking whether an operation succeeded or failed before proceeding to the next operation. Although in TypeScript we often use try/catch
blocks for error handling, we can skip the complexity of exceptions by this using a custom Result
type that represents either a success or a failure.
type Result<T> = {success: true, value: T} | {success: false, error: string};declare function isFailure<T>(result: Result<T>): result is {success: false, error: string};
declare function findUser(userId: string): Result<User>;declare function getProfile(user: User): Result<Profile>;declare function formatName(profile: Profile): string;
function processUser(userId: string): Result<string> { const user = findUser(userId); if (isFailure(user)) return user;
const profile = getProfile(user); if (isFailure(profile)) return profile;
return formatName(profile);}
As you can see, this case is similar to the nullable case, but instead of checking for undefined
, we check for a Failure
type.
In Haskell, we already have a built-in type for this kind of error handling called Either
, which can represent either a success or an error:
findUser :: String -> Either Error UsergetProfile :: User -> Either Error ProfileformatName :: Profile -> String
processUser :: String -> Either Error StringprocessUser userId = do user <- findUser userId profile <- getProfile user return (formatName profile)
In order to do the same but using exceptions, let’s make the example a bit more interesting by catching exceptions and use a default value in case of an error. In TypeScript, we might write:
declare function findUser(userId: string): User; // Throws an error if not founddeclare function getProfile(user: User): Profile; // Throws an error if not founddeclare function formatName(profile: Profile): string;
function processUser(userId: string): string { let user: User, profile: Profile; try { user = findUser(userId); } catch (error) { user = defaultUser; // Use a default user in case of an error }
try { profile = getProfile(user); } catch (error) { profile = defaultProfile; // Use a default profile in case of an error }
return formatName(profile);}
In Haskell, we could use an hypothetical IOCatch
Monad to handle exceptions, which allows us to catch exceptions and return a default value in case of an error:
findUser :: User -> String -> IOCatch User -- First argument is the default UsergetProfile :: Profile -> User -> IOCatch Profile -- First argument is the default ProfileformatName :: Profile -> String
processUser :: String -> IOCatch StringprocessUser userId = do user <- findUser defaultUser userId profile <- getProfile defaultProfile user return (formatName profile)
Example 4: Asynchronous Operations
Finally, let’s consider asynchronous operations. In TypeScript, we might use Promise
:
declare function findUser(userId: string): Promise<User>;declare function getProfile(user: User): Promise<Profile>;declare function formatName(profile: Profile): Promise<string>;
async function processUser(userId: string): Promise<string> { const user = await findUser(userId); const profile = await getProfile(user); return formatName(profile);}
In Haskell, although we don’t have Promise
per se, we could create a similar structure and use it like this:
findUser :: String -> AsyncMonad UsergetProfile :: User -> AsyncMonad ProfileformatName :: Profile -> String
processUser :: String -> AsyncMonad StringprocessUser userId = do user <- findUser userId profile <- getProfile user return (formatName profile)
The Monad Abstraction
Did you notice something interesting in the Haskell examples? The structure of the code remains consistent across all contexts, whether we’re dealing with nullable values, collections, error handling, or asynchronous operations. The only thing that changes is the type of the context (e.g., Maybe
, List
, Either
, IOCatch
, or AsyncMonad
), but the way we chain operations remains the same.
The key insight here is that all these examples share a common structure, which can be abstracted into a concrete pattern! What looks like completely different problems in TypeScript:
- Null checking with
undefined
- Array processing with
Array.flatMap()
- Promise chaining with
async/await
- Error handling with
try/catch
Are all instances of the same abstract pattern. Haskell’s type system captures this abstraction as Monads , allowing the same code structure to work across all these contexts.
In Haskell, whenever you see a <-
(the chaining operator), that means that, behind the scenes, the Monad type associated with that block of code is handling the chaining of operations implicitly, abstracting away the details of how each context works:
- For nullable values, the chaining operator automatically checks if the value is valid before proceeding.
- For collections, it iterates over each item and applies the next operation.
- For error handling, it checks if the operation succeeded or failed before proceeding.
- For asynchronous operations, it waits for the promise to resolve before proceeding.
Conclusion
The next time you’re chaining operations in any of these contexts, remember: you’re using the same fundamental pattern that Haskell makes explicit through its Monad abstraction. This pattern recognition is one of the most powerful tools in functional programming - seeing the forest through the trees of syntax and recognizing the fundamental structures that underlie our everyday code.
Lastly, is worth mentioning that this pattern recognition extends far beyond programming. In mathematics, the same principle drives entire research fields:
- Abstract algebra studies how the same algebraic structures (groups, rings, fields) appear across seemingly different mathematical contexts
- Measure theory reveals how the same integration and probability concepts apply whether you’re measuring lengths, areas, or probability distributions
- Category theory itself abstracts patterns that appear across all areas of mathematics