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 User
getProfile :: User -> Maybe Profile
formatName :: Profile -> String
processUser :: String -> Maybe String
processUser 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 User
getProfile :: User -> Either Error Profile
formatName :: Profile -> String
processUser :: String -> Either Error String
processUser 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 found
declare function getProfile(user: User): Profile; // Throws an error if not found
declare 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 User
getProfile :: Profile -> User -> IOCatch Profile -- First argument is the default Profile
formatName :: Profile -> String
processUser :: String -> IOCatch String
processUser 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 User
getProfile :: User -> AsyncMonad Profile
formatName :: Profile -> String
processUser :: String -> AsyncMonad String
processUser 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